burtenshaw HF Staff commited on
Commit
3661dd4
·
verified ·
1 Parent(s): 3c75da2

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base.
8
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
9
+ FROM ${BASE_IMAGE} AS builder
10
+
11
+ WORKDIR /app
12
+
13
+ # Ensure git is available for dependency installation.
14
+ RUN apt-get update && \
15
+ apt-get install -y --no-install-recommends git && \
16
+ rm -rf /var/lib/apt/lists/*
17
+
18
+ # Build argument to control whether we're building standalone or in-repo
19
+ ARG BUILD_MODE=in-repo
20
+ ARG ENV_NAME=terminus_env
21
+
22
+ # Copy environment code (always at root of build context)
23
+ COPY . /app/env
24
+
25
+ # For in-repo builds, openenv is already vendored in the build context
26
+ # For standalone builds, openenv will be installed via pyproject.toml
27
+ WORKDIR /app/env
28
+
29
+ # Ensure uv is available (for local builds where base image lacks it)
30
+ RUN if ! command -v uv >/dev/null 2>&1; then \
31
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
32
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
33
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
34
+ fi
35
+
36
+ # Install dependencies using uv sync
37
+ # If uv.lock exists, use it; otherwise resolve on the fly
38
+ RUN --mount=type=cache,target=/root/.cache/uv \
39
+ if [ -f uv.lock ]; then \
40
+ uv sync --frozen --no-install-project --no-editable; \
41
+ else \
42
+ uv sync --no-install-project --no-editable; \
43
+ fi
44
+
45
+ RUN --mount=type=cache,target=/root/.cache/uv \
46
+ if [ -f uv.lock ]; then \
47
+ uv sync --frozen --no-editable; \
48
+ else \
49
+ uv sync --no-editable; \
50
+ fi
51
+
52
+ # Final runtime stage.
53
+ FROM ${BASE_IMAGE}
54
+
55
+ WORKDIR /app
56
+
57
+ # Copy the virtual environment from builder
58
+ COPY --from=builder /app/env/.venv /app/.venv
59
+
60
+ # Copy the environment code
61
+ COPY --from=builder /app/env /app/env
62
+
63
+ # Set PATH to use the virtual environment
64
+ ENV PATH="/app/.venv/bin:$PATH"
65
+
66
+ # Set PYTHONPATH so imports work correctly
67
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
68
+ ENV ENABLE_WEB_INTERFACE=true
69
+
70
+ # Health check
71
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
72
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()" || exit 1
73
+
74
+ # Run the FastAPI server
75
+ # The module path is constructed to work with the /app/env structure
76
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md CHANGED
@@ -1,10 +1,95 @@
1
  ---
2
- title: Terminus Env
3
- emoji: 🔥
4
- colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
7
  pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Terminus Environment Server
3
+ emoji: 🛠️
4
+ colorFrom: green
5
  colorTo: blue
6
  sdk: docker
7
  pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ - terminus
13
+ - hf-sandbox
14
+ - coding
15
+ short_description: Single-tool HF Sandbox-backed coding environment
16
  ---
17
 
18
+ # Terminus Environment
19
+
20
+ `terminus_env` is a single-tool coding environment backed by
21
+ [hf-sandbox](https://github.com/huggingface/hf-sandbox). Each OpenEnv episode
22
+ creates a fresh Hugging Face Jobs-backed sandbox, runs optional
23
+ setup commands, keeps shell state and files isolated for that episode, and runs
24
+ optional verify commands when the agent submits a final answer.
25
+
26
+ The tool shape follows the Terminus-style "one tool" idea: agents do their work
27
+ through a single terminal entrypoint rather than a notebook/toolbox surface.
28
+
29
+ ## Tool
30
+
31
+ - `terminal(command="", final_answer="")`: run a shell command inside the
32
+ session sandbox, or submit a final answer and run verification.
33
+
34
+ ## Quick Start
35
+
36
+ ```python
37
+ from terminus_env import TerminusEnv
38
+
39
+ with TerminusEnv(base_url="http://localhost:8000").sync() as env:
40
+ env.reset(
41
+ setup=["mkdir -p /home/user/work"],
42
+ verify=["test -f /home/user/work/answer.txt"],
43
+ )
44
+ print(env.call_tool("terminal", command="echo done > /home/user/work/answer.txt"))
45
+ print(env.call_tool("terminal", final_answer="done"))
46
+ ```
47
+
48
+ ## Local Server
49
+
50
+ ```bash
51
+ cd envs/terminus_env
52
+ HF_TOKEN=hf_... uv run --project . server
53
+ ```
54
+
55
+ The API and custom terminal web UI are served on port 8000. The UI is mounted
56
+ at `/web`.
57
+
58
+ ## Docker
59
+
60
+ ```bash
61
+ cd envs/terminus_env
62
+ openenv build -t terminus-env
63
+ docker run -p 8000:8000 -e HF_TOKEN=hf_... terminus-env
64
+ ```
65
+
66
+ ## Configuration
67
+
68
+ - `HF_TOKEN`: required by `hf-sandbox` to launch Hugging Face Jobs.
69
+ - `HF_SANDBOX_IMAGE`: sandbox image. Defaults to `python:3.12`.
70
+ - `HF_SANDBOX_FLAVOR`: Hugging Face Jobs flavor. Defaults to `cpu-basic`.
71
+ - `HF_SANDBOX_TIMEOUT`: Hugging Face Jobs timeout. Defaults to `1h`.
72
+ - `HF_SANDBOX_FORWARD_HF_TOKEN`: forward `HF_TOKEN` into the sandbox. Defaults
73
+ to `false`.
74
+ - `MAX_CONCURRENT_ENVS`: maximum concurrent WebSocket sessions. Defaults to `4`.
75
+
76
+ ## Setup and Verify Commands
77
+
78
+ `reset()` accepts either `setup` / `verify` or `setup_scripts` /
79
+ `verify_scripts`.
80
+
81
+ ```python
82
+ env.reset(
83
+ setup=["pip install -q pytest"],
84
+ verify=["pytest -q /home/user/work/tests"],
85
+ )
86
+ ```
87
+
88
+ Setup failure ends the reset response with `done=True` and returns captured
89
+ setup results. Verify commands run when `terminal(final_answer="...")` is
90
+ called. Reward defaults to `passed_verify_commands / total_verify_commands`. A
91
+ verify command can override this by writing a float to:
92
+
93
+ ```text
94
+ /home/user/logs/verifier/reward.txt
95
+ ```
__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Terminus Environment for OpenEnv."""
8
+
9
+ from openenv.core.env_server.mcp_types import CallToolAction, ListToolsAction
10
+
11
+ from .client import TerminusEnv
12
+ from .harness import TerminusSessionFactory, build_terminal_tool_call
13
+ from .models import CommandResult, TerminusState
14
+
15
+ __all__ = [
16
+ "TerminusEnv",
17
+ "TerminusSessionFactory",
18
+ "TerminusState",
19
+ "CommandResult",
20
+ "CallToolAction",
21
+ "ListToolsAction",
22
+ "build_terminal_tool_call",
23
+ ]
client.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Client for the Terminus environment."""
8
+
9
+ from typing import Any
10
+
11
+ from openenv.core.mcp_client import MCPToolClient
12
+
13
+ from .models import CommandResult, TerminusState
14
+
15
+
16
+ class TerminusEnv(MCPToolClient):
17
+ """MCP client for calling the Terminus single-rollout tool."""
18
+
19
+ def _parse_state(self, payload: dict[str, Any]) -> TerminusState:
20
+ """Convert server state payloads to the Terminus state model."""
21
+
22
+ def command_results(name: str) -> list[CommandResult]:
23
+ values = payload.get(name, [])
24
+ if not isinstance(values, list):
25
+ return []
26
+ return [
27
+ value if isinstance(value, CommandResult) else CommandResult(**value)
28
+ for value in values
29
+ if isinstance(value, dict) or isinstance(value, CommandResult)
30
+ ]
31
+
32
+ return TerminusState(
33
+ episode_id=payload.get("episode_id"),
34
+ step_count=payload.get("step_count", 0),
35
+ sandbox_id=payload.get("sandbox_id"),
36
+ setup_results=command_results("setup_results"),
37
+ verify_commands=list(payload.get("verify_commands", []) or []),
38
+ verify_results=command_results("verify_results"),
39
+ commands=command_results("commands"),
40
+ submitted_answer=payload.get("submitted_answer"),
41
+ last_reward=payload.get("last_reward"),
42
+ last_error=payload.get("last_error"),
43
+ )
harness.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Harness-oriented Terminus session adapter."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any, Callable
13
+
14
+ from openenv.core.env_server.mcp_types import CallToolAction, Tool
15
+ from openenv.core.harness import (
16
+ ResourceSessionFactory,
17
+ StepEnvSessionAdapter,
18
+ ToolResult,
19
+ VerifyResult,
20
+ )
21
+
22
+ from .client import TerminusEnv
23
+
24
+ _TERMINUS_TOOLS: list[Tool] = [
25
+ Tool(
26
+ name="terminal",
27
+ description=(
28
+ "Run a shell command in the Terminus sandbox, or submit final_answer "
29
+ "to trigger verification."
30
+ ),
31
+ input_schema={
32
+ "type": "object",
33
+ "properties": {
34
+ "command": {
35
+ "type": "string",
36
+ "description": "Shell command to run in the sandbox.",
37
+ },
38
+ "final_answer": {
39
+ "type": "string",
40
+ "description": "Final answer to submit when the task is complete.",
41
+ },
42
+ },
43
+ "additionalProperties": False,
44
+ },
45
+ )
46
+ ]
47
+
48
+
49
+ def _task_field(task: Any, *names: str, default: Any = None) -> Any:
50
+ if not isinstance(task, dict):
51
+ return default
52
+ for name in names:
53
+ value = task.get(name)
54
+ if value is not None:
55
+ return value
56
+ return default
57
+
58
+
59
+ def _coerce_commands(value: Any) -> list[str]:
60
+ if value is None:
61
+ return []
62
+ if isinstance(value, str):
63
+ return [value] if value.strip() else []
64
+ return [str(item) for item in value if str(item).strip()]
65
+
66
+
67
+ def _format_initial_prompt(result: Any, task: Any) -> str:
68
+ if isinstance(task, str):
69
+ instruction = task
70
+ setup_commands: list[str] = []
71
+ verify_commands: list[str] = []
72
+ elif isinstance(task, list):
73
+ user_messages = [
74
+ item.get("content")
75
+ for item in task
76
+ if isinstance(item, dict) and item.get("role") == "user"
77
+ ]
78
+ instruction = str(user_messages[-1] if user_messages else task)
79
+ setup_commands = []
80
+ verify_commands = []
81
+ elif isinstance(task, dict):
82
+ instruction = str(
83
+ _task_field(task, "instruction", "prompt", "question", "task", default="")
84
+ )
85
+ setup_commands = _coerce_commands(_task_field(task, "setup", "setup_scripts"))
86
+ verify_commands = _coerce_commands(_task_field(task, "verify", "verify_scripts"))
87
+ else:
88
+ instruction = str(task or "")
89
+ setup_commands = []
90
+ verify_commands = []
91
+
92
+ metadata = getattr(result.observation, "metadata", {}) or {}
93
+ verify_commands = _coerce_commands(
94
+ metadata.get("verify_commands") or verify_commands
95
+ )
96
+
97
+ parts = []
98
+ if instruction:
99
+ parts.append(f"Task:\n{instruction}")
100
+ else:
101
+ parts.append("Task:\nUse the terminal tool to solve the current task.")
102
+
103
+ reset_message = metadata.get("message")
104
+ if reset_message:
105
+ parts.append(f"Environment:\n{reset_message}")
106
+
107
+ if setup_commands:
108
+ parts.append(
109
+ "Setup commands have already run:\n"
110
+ + "\n".join(f"- {command}" for command in setup_commands)
111
+ )
112
+ if verify_commands:
113
+ parts.append(
114
+ "Verification commands will run after final_answer:\n"
115
+ + "\n".join(f"- {command}" for command in verify_commands)
116
+ )
117
+
118
+ parts.append(
119
+ "Use terminal(command=...) to inspect and modify the sandbox. "
120
+ "When finished, call terminal(final_answer=...) exactly once so "
121
+ "verification runs and emits the environment reward."
122
+ )
123
+ return "\n\n".join(parts)
124
+
125
+
126
+ def _extract_tool_output(observation: Any) -> Any:
127
+ result = getattr(observation, "result", None)
128
+ if result is None:
129
+ return None
130
+ if hasattr(result, "data"):
131
+ return result.data
132
+ if isinstance(result, dict):
133
+ if "data" in result:
134
+ return result["data"]
135
+ content = result.get("content")
136
+ if isinstance(content, list):
137
+ texts = [
138
+ str(item.get("text"))
139
+ for item in content
140
+ if isinstance(item, dict) and item.get("text") is not None
141
+ ]
142
+ if texts:
143
+ return "\n".join(texts)
144
+ return result
145
+ content = getattr(result, "content", None)
146
+ if isinstance(content, list):
147
+ texts = [
148
+ getattr(item, "text", None)
149
+ for item in content
150
+ if getattr(item, "text", None) is not None
151
+ ]
152
+ if texts:
153
+ return "\n".join(texts)
154
+ return result
155
+
156
+
157
+ def _tool_error_message(observation: Any) -> str | None:
158
+ error = getattr(observation, "error", None)
159
+ if error is None:
160
+ return None
161
+ message = getattr(error, "message", None)
162
+ if message is not None:
163
+ return str(message)
164
+ if isinstance(error, dict):
165
+ return str(error.get("message") or error)
166
+ return str(error)
167
+
168
+
169
+ def _state_to_data(state: Any) -> Any:
170
+ if state is None:
171
+ return None
172
+ if hasattr(state, "model_dump"):
173
+ return state.model_dump()
174
+ return state
175
+
176
+
177
+ def _build_tool_result(
178
+ tool_name: str,
179
+ arguments: dict[str, Any],
180
+ result: Any,
181
+ state: Any,
182
+ ) -> ToolResult:
183
+ output = _extract_tool_output(result.observation)
184
+ error = _tool_error_message(result.observation)
185
+ data = {
186
+ "tool_name": tool_name,
187
+ "arguments": dict(arguments),
188
+ "output": output,
189
+ "reward": result.reward,
190
+ "done": result.done,
191
+ }
192
+ if error:
193
+ data["error"] = error
194
+
195
+ return ToolResult(
196
+ data=data,
197
+ done=bool(result.done),
198
+ error=error,
199
+ metadata={
200
+ "reward": result.reward,
201
+ "state": _state_to_data(state),
202
+ },
203
+ )
204
+
205
+
206
+ def _build_verify(
207
+ transcript: list[dict[str, Any]],
208
+ final_state: Any | None,
209
+ last_result: Any | None,
210
+ state: Any,
211
+ ) -> VerifyResult:
212
+ reward = None if last_result is None else last_result.reward
213
+ done = False if last_result is None else bool(last_result.done)
214
+ state_data = _state_to_data(state)
215
+ metrics = {
216
+ "done": done,
217
+ "step_count": getattr(state, "step_count", 0),
218
+ "commands": len(getattr(state, "commands", []) or []),
219
+ "verify_commands": len(getattr(state, "verify_commands", []) or []),
220
+ "setup_commands": len(getattr(state, "setup_results", []) or []),
221
+ "submitted_answer": getattr(state, "submitted_answer", None) is not None,
222
+ "sandbox_id": getattr(state, "sandbox_id", None),
223
+ }
224
+ if state is None and last_result is not None:
225
+ metrics["step_count"] = len(transcript)
226
+ return VerifyResult(
227
+ env_reward=reward,
228
+ done=done,
229
+ metrics=metrics,
230
+ artifacts={
231
+ "final_state": state_data,
232
+ "final_rollout": final_state,
233
+ "transcript_length": len(transcript),
234
+ },
235
+ )
236
+
237
+
238
+ def _build_reset_kwargs(
239
+ task: Any,
240
+ default_setup: list[str],
241
+ default_verify: list[str],
242
+ default_sandbox: dict[str, Any],
243
+ ) -> dict[str, Any]:
244
+ reset_kwargs: dict[str, Any] = dict(default_sandbox)
245
+ setup = list(default_setup)
246
+ verify = list(default_verify)
247
+ if isinstance(task, dict):
248
+ setup = _coerce_commands(_task_field(task, "setup", "setup_scripts", default=setup))
249
+ verify = _coerce_commands(
250
+ _task_field(task, "verify", "verify_scripts", default=verify)
251
+ )
252
+ for key in (
253
+ "sandbox_image",
254
+ "sandbox_flavor",
255
+ "sandbox_timeout",
256
+ "hf_sandbox_image",
257
+ "hf_sandbox_flavor",
258
+ "hf_sandbox_timeout",
259
+ "forward_hf_token",
260
+ ):
261
+ if key in task:
262
+ reset_kwargs[key] = task[key]
263
+
264
+ if setup:
265
+ reset_kwargs["setup"] = setup
266
+ if verify:
267
+ reset_kwargs["verify"] = verify
268
+ return reset_kwargs
269
+
270
+
271
+ class TerminusSessionFactory(ResourceSessionFactory):
272
+ """Create Terminus-backed resource sessions for harness rollouts."""
273
+
274
+ def __init__(
275
+ self,
276
+ client_factory: Callable[[], TerminusEnv],
277
+ *,
278
+ default_setup: list[str] | None = None,
279
+ default_verify: list[str] | None = None,
280
+ sandbox: dict[str, Any] | None = None,
281
+ ):
282
+ self._client_factory = client_factory
283
+ self._default_setup = list(default_setup or [])
284
+ self._default_verify = list(default_verify or [])
285
+ self._sandbox = dict(sandbox or {})
286
+
287
+ def create(
288
+ self,
289
+ task: Any = None,
290
+ seed: int | None = None,
291
+ episode_id: str | None = None,
292
+ ) -> StepEnvSessionAdapter:
293
+ reset_kwargs = _build_reset_kwargs(
294
+ task,
295
+ self._default_setup,
296
+ self._default_verify,
297
+ self._sandbox,
298
+ )
299
+
300
+ return StepEnvSessionAdapter(
301
+ client=self._client_factory(),
302
+ task=task,
303
+ seed=seed,
304
+ episode_id=episode_id,
305
+ tool_specs=list(_TERMINUS_TOOLS),
306
+ action_builder=lambda name, arguments: CallToolAction(
307
+ tool_name=name,
308
+ arguments=dict(arguments),
309
+ ),
310
+ initial_messages_builder=lambda result, current_task: [
311
+ {
312
+ "role": "user",
313
+ "content": _format_initial_prompt(result, current_task),
314
+ }
315
+ ],
316
+ tool_result_builder=_build_tool_result,
317
+ verify_builder=_build_verify,
318
+ reset_kwargs=reset_kwargs,
319
+ )
320
+
321
+
322
+ def build_terminal_tool_call(response_text: str, *, call_id: str = "terminal-0"):
323
+ """Parse a simple JSON terminal call from model text.
324
+
325
+ The preferred format is one JSON object containing ``command`` or
326
+ ``final_answer``. Invalid text falls back to a no-op shell command so the
327
+ environment, not this parser, decides whether a rollout earns reward.
328
+ """
329
+
330
+ from openenv.core.llm_client import ToolCall
331
+
332
+ text = response_text.strip()
333
+ if text.startswith("```"):
334
+ text = text.strip("`").strip()
335
+ if text.startswith("json"):
336
+ text = text[4:].strip()
337
+ try:
338
+ payload = json.loads(text)
339
+ except json.JSONDecodeError:
340
+ payload = {"command": response_text}
341
+ if not isinstance(payload, dict):
342
+ payload = {"command": response_text}
343
+ arguments = {
344
+ key: str(payload[key])
345
+ for key in ("command", "final_answer")
346
+ if payload.get(key) is not None
347
+ }
348
+ if not arguments:
349
+ arguments = {"command": ""}
350
+ return ToolCall(id=call_id, name="terminal", args=arguments)
351
+
352
+
353
+ __all__ = [
354
+ "TerminusSessionFactory",
355
+ "build_terminal_tool_call",
356
+ ]
models.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Models for the Terminus environment."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from openenv.core.env_server.types import State
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class CommandResult(BaseModel):
16
+ """Outcome of one shell command run inside the HF sandbox."""
17
+
18
+ command: str
19
+ output: str = ""
20
+ error: str | None = None
21
+ success: bool = True
22
+
23
+
24
+ class TerminusState(State):
25
+ """Per-session state for the single-tool Terminus environment."""
26
+
27
+ sandbox_id: str | None = None
28
+ setup_results: list[CommandResult] = Field(default_factory=list)
29
+ verify_commands: list[str] = Field(default_factory=list)
30
+ verify_results: list[CommandResult] = Field(default_factory=list)
31
+ commands: list[CommandResult] = Field(default_factory=list)
32
+ submitted_answer: str | None = None
33
+ last_reward: float | None = None
34
+ last_error: str | None = None
openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: terminus_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
openenv_terminus_env.egg-info/PKG-INFO ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: openenv-terminus-env
3
+ Version: 0.1.0
4
+ Summary: Single-tool HF Sandbox-backed coding environment for OpenEnv
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: openenv-core[core]>=0.2.2
7
+ Requires-Dist: hf-sandbox>=0.1.1
8
+ Requires-Dist: fastapi>=0.115.0
9
+ Requires-Dist: fastmcp>=3.0.0
10
+ Requires-Dist: gradio>=4.0.0
11
+ Requires-Dist: pydantic>=2.0.0
12
+ Requires-Dist: requests>=2.31.0
13
+ Requires-Dist: uvicorn>=0.24.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
16
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
openenv_terminus_env.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ ./__init__.py
4
+ ./client.py
5
+ ./harness.py
6
+ ./models.py
7
+ ./openenv.yaml
8
+ ./openenv_terminus_env.egg-info/SOURCES.txt
9
+ ./openenv_terminus_env.egg-info/dependency_links.txt
10
+ ./openenv_terminus_env.egg-info/entry_points.txt
11
+ ./openenv_terminus_env.egg-info/requires.txt
12
+ ./openenv_terminus_env.egg-info/top_level.txt
13
+ ./server/requirements.txt
14
+ openenv_terminus_env.egg-info/PKG-INFO
15
+ openenv_terminus_env.egg-info/SOURCES.txt
16
+ openenv_terminus_env.egg-info/dependency_links.txt
17
+ openenv_terminus_env.egg-info/entry_points.txt
18
+ openenv_terminus_env.egg-info/requires.txt
19
+ openenv_terminus_env.egg-info/top_level.txt
20
+ server/__init__.py
21
+ server/app.py
22
+ server/gradio_ui.py
23
+ server/hf_sandbox.py
24
+ server/terminus_env_environment.py
openenv_terminus_env.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
openenv_terminus_env.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ server = terminus_env.server.app:main
openenv_terminus_env.egg-info/requires.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.2
2
+ hf-sandbox>=0.1.1
3
+ fastapi>=0.115.0
4
+ fastmcp>=3.0.0
5
+ gradio>=4.0.0
6
+ pydantic>=2.0.0
7
+ requests>=2.31.0
8
+ uvicorn>=0.24.0
9
+
10
+ [dev]
11
+ pytest>=8.0.0
12
+ pytest-cov>=4.0.0
openenv_terminus_env.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ terminus_env
pyproject.toml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-terminus-env"
13
+ version = "0.1.0"
14
+ description = "Single-tool HF Sandbox-backed coding environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ "openenv-core[core]>=0.2.2",
18
+ "hf-sandbox>=0.1.1",
19
+ "fastapi>=0.115.0",
20
+ "fastmcp>=3.0.0",
21
+ "gradio>=4.0.0",
22
+ "pydantic>=2.0.0",
23
+ "requests>=2.31.0",
24
+ "uvicorn>=0.24.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0.0",
30
+ "pytest-cov>=4.0.0",
31
+ ]
32
+
33
+ [project.scripts]
34
+ server = "terminus_env.server.app:main"
35
+
36
+ [tool.setuptools]
37
+ include-package-data = true
38
+ packages = ["terminus_env", "terminus_env.server"]
39
+ package-dir = { "terminus_env" = ".", "terminus_env.server" = "server" }
40
+
41
+ [tool.setuptools.package-data]
42
+ terminus_env = ["**/*.txt", "**/*.yaml"]
server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Terminus Env environment server components."""
8
+
9
+ from .terminus_env_environment import TerminusEnvironment
10
+
11
+ __all__ = ["TerminusEnvironment"]
server/app.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """FastAPI app for the Terminus environment."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from openenv.core.env_server.http_server import create_app
15
+ from openenv.core.env_server.mcp_types import CallToolAction, CallToolObservation
16
+
17
+ try:
18
+ from .gradio_ui import terminus_ui_builder
19
+ from .terminus_env_environment import TerminusEnvironment
20
+ except ImportError: # pragma: no cover
21
+ from server.gradio_ui import terminus_ui_builder # type: ignore
22
+ from server.terminus_env_environment import TerminusEnvironment # type: ignore
23
+
24
+
25
+ def _load_env_file() -> None:
26
+ candidate = Path(__file__).resolve().parents[1] / ".env"
27
+ if not candidate.exists():
28
+ return
29
+ for raw in candidate.read_text(encoding="utf-8").splitlines():
30
+ line = raw.strip()
31
+ if not line or line.startswith("#") or "=" not in line:
32
+ continue
33
+ key, _, value = line.partition("=")
34
+ key = key.strip()
35
+ value = value.strip().strip('"').strip("'")
36
+ if key and key not in os.environ:
37
+ os.environ[key] = value
38
+
39
+
40
+ _load_env_file()
41
+ os.environ.setdefault("ENABLE_WEB_INTERFACE", "true")
42
+
43
+ app = create_app(
44
+ TerminusEnvironment,
45
+ CallToolAction,
46
+ CallToolObservation,
47
+ env_name="terminus_env",
48
+ max_concurrent_envs=int(os.getenv("MAX_CONCURRENT_ENVS", "4")),
49
+ gradio_builder=terminus_ui_builder,
50
+ )
51
+
52
+
53
+ def main(host: str = "0.0.0.0", port: int = 8000) -> None:
54
+ import uvicorn
55
+
56
+ uvicorn.run(app, host=host, port=port)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
server/gradio_ui.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Custom Gradio terminal UI for the Terminus environment."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+ import gradio as gr
14
+
15
+
16
+ COMMAND_EXAMPLES = {
17
+ "Inspect workspace": "pwd && ls -la",
18
+ "Create answer file": "mkdir -p /home/user/work && echo done > /home/user/work/answer.txt",
19
+ "Run Python": "python - <<'PY'\nprint('hello from the sandbox')\nPY",
20
+ "Install package": "pip install -q pytest",
21
+ "Run tests": "pytest -q",
22
+ }
23
+
24
+ DEFAULT_SETUP = "mkdir -p /home/user/work"
25
+ DEFAULT_VERIFY = "test -f /home/user/work/answer.txt"
26
+
27
+ _STYLE = """
28
+ <style>
29
+ .term-shell {
30
+ background: #020403;
31
+ border: 1px solid #16a34a;
32
+ box-shadow: inset 0 0 0 1px #052e16, 0 0 24px rgba(22, 163, 74, 0.18);
33
+ color: #d1fae5;
34
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
35
+ min-height: 520px;
36
+ max-height: 720px;
37
+ overflow-y: auto;
38
+ padding: 14px;
39
+ }
40
+ .term-empty {
41
+ color: #6ee7b7;
42
+ padding: 30px 10px;
43
+ text-align: center;
44
+ }
45
+ .term-entry {
46
+ border-bottom: 1px solid #064e3b;
47
+ padding: 10px 0 12px;
48
+ }
49
+ .term-entry:last-child { border-bottom: 0; }
50
+ .term-meta {
51
+ color: #34d399;
52
+ font-size: 12px;
53
+ margin-bottom: 6px;
54
+ text-transform: uppercase;
55
+ letter-spacing: 0;
56
+ }
57
+ .term-prompt {
58
+ color: #22c55e;
59
+ white-space: pre-wrap;
60
+ word-break: break-word;
61
+ }
62
+ .term-prompt::before {
63
+ color: #86efac;
64
+ }
65
+ .term-output {
66
+ color: #d1fae5;
67
+ margin-top: 8px;
68
+ white-space: pre-wrap;
69
+ word-break: break-word;
70
+ }
71
+ .term-error {
72
+ color: #fca5a5;
73
+ margin-top: 8px;
74
+ white-space: pre-wrap;
75
+ word-break: break-word;
76
+ }
77
+ .term-success { color: #86efac; }
78
+ .term-fail { color: #f87171; }
79
+ .term-final {
80
+ border: 1px solid #16a34a;
81
+ background: #04130a;
82
+ margin-top: 12px;
83
+ padding: 12px;
84
+ }
85
+ </style>
86
+ """
87
+
88
+ _EMPTY_TERMINAL = (
89
+ "<div class='term-shell'><div class='term-empty'>"
90
+ "Reset the environment to create an HF sandbox, then run commands."
91
+ "</div></div>"
92
+ )
93
+
94
+
95
+ def _escape(value: Any) -> str:
96
+ return str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
97
+
98
+
99
+ def _lines(value: str) -> list[str]:
100
+ return [line.strip() for line in value.splitlines() if line.strip()]
101
+
102
+
103
+ def _extract_tool_text(result: dict[str, Any]) -> str:
104
+ obs = result.get("observation", {})
105
+ if not isinstance(obs, dict):
106
+ return ""
107
+ tool_result = obs.get("result", {})
108
+ if not isinstance(tool_result, dict):
109
+ return ""
110
+ content = tool_result.get("content", [])
111
+ if isinstance(content, list) and content:
112
+ first = content[0]
113
+ if isinstance(first, dict):
114
+ return str(first.get("text", ""))
115
+ return str(tool_result.get("data", ""))
116
+
117
+
118
+ def _state_summary(state: dict[str, Any]) -> dict[str, Any]:
119
+ return {
120
+ "episode_id": state.get("episode_id"),
121
+ "sandbox_id": state.get("sandbox_id"),
122
+ "step_count": state.get("step_count", 0),
123
+ "commands": len(state.get("commands", [])),
124
+ "setup_commands": len(state.get("setup_results", [])),
125
+ "verify_commands": len(state.get("verify_commands", [])),
126
+ "last_reward": state.get("last_reward"),
127
+ "last_error": state.get("last_error"),
128
+ }
129
+
130
+
131
+ def render_terminal_html(state: dict[str, Any]) -> str:
132
+ entries: list[str] = []
133
+
134
+ for index, item in enumerate(state.get("setup_results", []), start=1):
135
+ status = "ok" if item.get("success", False) else "failed"
136
+ status_cls = "term-success" if item.get("success", False) else "term-fail"
137
+ entries.append(
138
+ "<div class='term-entry'>"
139
+ f"<div class='term-meta'>setup {index} - <span class='{status_cls}'>{status}</span></div>"
140
+ f"<div class='term-prompt'>$ {_escape(item.get('command', ''))}</div>"
141
+ f"<div class='term-output'>{_escape(item.get('output', ''))}</div>"
142
+ f"{_error_html(item)}"
143
+ "</div>"
144
+ )
145
+
146
+ for index, item in enumerate(state.get("commands", []), start=1):
147
+ status = "ok" if item.get("success", False) else "failed"
148
+ status_cls = "term-success" if item.get("success", False) else "term-fail"
149
+ entries.append(
150
+ "<div class='term-entry'>"
151
+ f"<div class='term-meta'>command {index} - <span class='{status_cls}'>{status}</span></div>"
152
+ f"<div class='term-prompt'>$ {_escape(item.get('command', ''))}</div>"
153
+ f"<div class='term-output'>{_escape(item.get('output', ''))}</div>"
154
+ f"{_error_html(item)}"
155
+ "</div>"
156
+ )
157
+
158
+ if state.get("submitted_answer") is not None:
159
+ answer = _escape(state.get("submitted_answer", ""))
160
+ reward = _escape(state.get("last_reward", ""))
161
+ entries.append(
162
+ "<div class='term-final'>"
163
+ "<div class='term-meta'>final answer</div>"
164
+ f"<div>{answer}</div>"
165
+ f"<div class='term-meta'>reward: {reward}</div>"
166
+ "</div>"
167
+ )
168
+
169
+ if not entries:
170
+ return _STYLE + _EMPTY_TERMINAL
171
+ return _STYLE + "<div class='term-shell'>" + "\n".join(entries) + "</div>"
172
+
173
+
174
+ def _closed_terminal_html() -> str:
175
+ return (
176
+ _STYLE + "<div class='term-shell'><div class='term-empty'>"
177
+ "Session closed. Reset the sandbox to start a new terminal."
178
+ "</div></div>"
179
+ )
180
+
181
+
182
+ def _error_html(item: dict[str, Any]) -> str:
183
+ if not item.get("error"):
184
+ return ""
185
+ return f"<div class='term-error'>{_escape(item['error'])}</div>"
186
+
187
+
188
+ def terminus_ui_builder(
189
+ web_manager,
190
+ action_fields: list[dict[str, Any]],
191
+ metadata: Optional[Any],
192
+ is_chat_env: bool,
193
+ title: str,
194
+ quick_start_md: Optional[str],
195
+ ) -> gr.Blocks:
196
+ def current_state() -> dict[str, Any]:
197
+ try:
198
+ return web_manager.get_state()
199
+ except RuntimeError:
200
+ return {}
201
+
202
+ with gr.Blocks(title=f"{title} - Terminal") as demo:
203
+ gr.Markdown(f"# {title}")
204
+ gr.Markdown(
205
+ "Single-tool terminal environment backed by an HF sandbox. "
206
+ "Reset creates a fresh session; commands run through `terminal(command=...)`."
207
+ )
208
+
209
+ with gr.Row():
210
+ with gr.Column(scale=1, min_width=320):
211
+ gr.Markdown("### Episode")
212
+ setup_input = gr.Textbox(
213
+ label="Setup commands",
214
+ value=DEFAULT_SETUP,
215
+ lines=5,
216
+ placeholder="One shell command per line",
217
+ )
218
+ verify_input = gr.Textbox(
219
+ label="Verify commands",
220
+ value=DEFAULT_VERIFY,
221
+ lines=4,
222
+ placeholder="One shell command per line",
223
+ )
224
+ reset_btn = gr.Button("Reset sandbox", variant="primary", size="lg")
225
+ close_btn = gr.Button("Stop / Close session", variant="secondary")
226
+ state_box = gr.JSON(label="Session state", value={})
227
+
228
+ gr.Markdown("### Command")
229
+ example_dd = gr.Dropdown(
230
+ choices=list(COMMAND_EXAMPLES.keys()),
231
+ label="Load example",
232
+ value=None,
233
+ interactive=True,
234
+ )
235
+ command_input = gr.Textbox(
236
+ label="Terminal command",
237
+ value="pwd && ls -la",
238
+ lines=6,
239
+ placeholder="Run shell commands in the HF sandbox",
240
+ )
241
+ run_btn = gr.Button("Run command", variant="primary")
242
+
243
+ gr.Markdown("### Submit")
244
+ answer_input = gr.Textbox(
245
+ label="Final answer",
246
+ value="done",
247
+ lines=2,
248
+ )
249
+ submit_btn = gr.Button("Submit final answer", variant="secondary")
250
+
251
+ with gr.Column(scale=2):
252
+ gr.Markdown("### Terminal")
253
+ terminal_display = gr.HTML(value=_STYLE + _EMPTY_TERMINAL)
254
+ status_bar = gr.Textbox(
255
+ label="Status",
256
+ value="Reset the sandbox before running commands.",
257
+ interactive=False,
258
+ lines=2,
259
+ )
260
+
261
+ async def on_reset(setup_text: str, verify_text: str):
262
+ payload = {
263
+ "setup": _lines(setup_text),
264
+ "verify": _lines(verify_text),
265
+ }
266
+ result = await web_manager.reset_environment(payload)
267
+ state = current_state()
268
+ done = result.get("done", False)
269
+ metadata = result.get("metadata", {})
270
+ if done and isinstance(metadata, dict):
271
+ status = (
272
+ metadata.get("error") or metadata.get("message") or "Reset failed."
273
+ )
274
+ else:
275
+ status = "Sandbox reset. Setup and verify configuration applied."
276
+ return render_terminal_html(state), _state_summary(state), status
277
+
278
+ async def on_close():
279
+ await web_manager._run_sync_in_thread_pool(web_manager.env.close)
280
+ return (
281
+ _closed_terminal_html(),
282
+ {},
283
+ "Session closed. The HF sandbox was released.",
284
+ )
285
+
286
+ async def on_run(command: str):
287
+ if not command.strip():
288
+ return gr.update(), gr.update(), "No command provided."
289
+ result = await web_manager.step_environment(
290
+ {
291
+ "tool_name": "terminal",
292
+ "arguments": {"command": command},
293
+ }
294
+ )
295
+ state = current_state()
296
+ text = _extract_tool_text(result)
297
+ status = text if text.startswith("Error:") else "Command completed."
298
+ return render_terminal_html(state), _state_summary(state), status
299
+
300
+ async def on_submit(answer: str):
301
+ if not answer.strip():
302
+ return gr.update(), gr.update(), "No final answer provided."
303
+ result = await web_manager.step_environment(
304
+ {
305
+ "tool_name": "terminal",
306
+ "arguments": {"final_answer": answer},
307
+ }
308
+ )
309
+ state = current_state()
310
+ status = _extract_tool_text(result) or "Final answer submitted."
311
+ return render_terminal_html(state), _state_summary(state), status
312
+
313
+ def on_example(choice: str):
314
+ if choice and choice in COMMAND_EXAMPLES:
315
+ return COMMAND_EXAMPLES[choice]
316
+ return gr.update()
317
+
318
+ reset_btn.click(
319
+ fn=on_reset,
320
+ inputs=[setup_input, verify_input],
321
+ outputs=[terminal_display, state_box, status_bar],
322
+ )
323
+ close_btn.click(
324
+ fn=on_close,
325
+ outputs=[terminal_display, state_box, status_bar],
326
+ )
327
+ run_btn.click(
328
+ fn=on_run,
329
+ inputs=[command_input],
330
+ outputs=[terminal_display, state_box, status_bar],
331
+ )
332
+ submit_btn.click(
333
+ fn=on_submit,
334
+ inputs=[answer_input],
335
+ outputs=[terminal_display, state_box, status_bar],
336
+ )
337
+ example_dd.change(
338
+ fn=on_example,
339
+ inputs=[example_dd],
340
+ outputs=[command_input],
341
+ )
342
+
343
+ return demo
server/hf_sandbox.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Small hf-sandbox wrapper for terminal-style environments."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+
14
+ _HF_SANDBOX_IMPORT_ERROR: ImportError | None = None
15
+
16
+ try:
17
+ from hf_sandbox import Sandbox
18
+ except ImportError as _hf_sandbox_import_error: # pragma: no cover
19
+ _HF_SANDBOX_IMPORT_ERROR = _hf_sandbox_import_error
20
+ Sandbox = None # type: ignore[assignment]
21
+
22
+
23
+ DEFAULT_IMAGE = "python:3.12"
24
+ DEFAULT_FLAVOR = "cpu-basic"
25
+ DEFAULT_TIMEOUT = "1h"
26
+
27
+
28
+ @dataclass
29
+ class ShellResult:
30
+ """Normalized result from a command executed in an HF sandbox."""
31
+
32
+ stdout: str
33
+ stderr: str
34
+ error: str | None
35
+ success: bool
36
+
37
+
38
+ class HFSandbox:
39
+ """Manages one hf-sandbox job for one OpenEnv episode."""
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ image: str | None = None,
45
+ flavor: str | None = None,
46
+ timeout: str | None = None,
47
+ forward_hf_token: bool | None = None,
48
+ ):
49
+ if Sandbox is None:
50
+ raise ImportError(
51
+ "hf-sandbox is not installed. Install the terminus_env package "
52
+ "dependencies to use HFSandbox. Original import error: "
53
+ f"{_HF_SANDBOX_IMPORT_ERROR}"
54
+ )
55
+
56
+ resolved_forward = _coerce_bool(
57
+ os.getenv("HF_SANDBOX_FORWARD_HF_TOKEN", "false")
58
+ )
59
+ if forward_hf_token is not None:
60
+ resolved_forward = bool(forward_hf_token)
61
+
62
+ self._sandbox = Sandbox.create(
63
+ image=image or os.getenv("HF_SANDBOX_IMAGE", DEFAULT_IMAGE),
64
+ flavor=flavor or os.getenv("HF_SANDBOX_FLAVOR", DEFAULT_FLAVOR),
65
+ timeout=timeout or os.getenv("HF_SANDBOX_TIMEOUT", DEFAULT_TIMEOUT),
66
+ forward_hf_token=resolved_forward,
67
+ )
68
+ self.sandbox_id: str = self._sandbox.job_id
69
+
70
+ def run_shell(self, command: str, timeout_s: int = 120) -> ShellResult:
71
+ process = self._sandbox.exec(
72
+ "bash",
73
+ "-lc",
74
+ command,
75
+ timeout=timeout_s,
76
+ )
77
+ success = process.returncode == 0
78
+ return ShellResult(
79
+ stdout=process.stdout or "",
80
+ stderr=process.stderr or "",
81
+ error=None if success else f"exit code {process.returncode}",
82
+ success=success,
83
+ )
84
+
85
+ def kill(self) -> None:
86
+ try:
87
+ self._sandbox.terminate()
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ def _coerce_bool(value: str) -> bool:
93
+ return value.strip().lower() in {"1", "true", "yes", "on"}
server/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.2
2
+ hf-sandbox>=0.1.1
3
+ fastapi>=0.115.0
4
+ fastmcp>=3.0.0
5
+ gradio>=4.0.0
6
+ pydantic>=2.0.0
7
+ requests>=2.31.0
8
+ uvicorn>=0.24.0
server/terminus_env_environment.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """HF Sandbox-backed single-tool coding environment inspired by Terminus."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Iterable, Optional
12
+ from uuid import uuid4
13
+
14
+ from fastmcp import FastMCP
15
+ from openenv.core.env_server.mcp_environment import MCPEnvironment
16
+ from openenv.core.env_server.types import Action, Observation
17
+
18
+ try:
19
+ from .hf_sandbox import HFSandbox
20
+ from ..models import CommandResult, TerminusState
21
+ except ImportError: # pragma: no cover
22
+ from models import CommandResult, TerminusState
23
+ from server.hf_sandbox import HFSandbox
24
+
25
+
26
+ REWARD_FILE = "/home/user/logs/verifier/reward.txt"
27
+
28
+
29
+ class TerminusEnvironment(MCPEnvironment):
30
+ """Single-tool terminal environment with one HF Sandbox job per episode."""
31
+
32
+ SUPPORTS_CONCURRENT_SESSIONS = True
33
+
34
+ def __init__(self):
35
+ self._sandbox: Optional[HFSandbox] = None
36
+ self._state = TerminusState(episode_id=str(uuid4()), step_count=0)
37
+
38
+ mcp = FastMCP("terminus_env")
39
+
40
+ @mcp.tool
41
+ def terminal(command: str = "", final_answer: str = "") -> str:
42
+ """Run a shell command or submit a final answer inside the sandbox.
43
+
44
+ Args:
45
+ command: Shell command to execute in the episode's HF Sandbox.
46
+ final_answer: Optional answer string. When provided, stored
47
+ as the final answer and any reset-time verify commands run.
48
+
49
+ Returns:
50
+ Command output, or final-answer verification summary.
51
+ """
52
+ if not self._sandbox:
53
+ return "Error: environment not reset. Call reset() first."
54
+ if final_answer:
55
+ self._state.submitted_answer = final_answer
56
+ if not self._state.verify_commands:
57
+ return f"Answer submitted: {final_answer}"
58
+ summary = self._run_verify_commands()
59
+ return (
60
+ f"Answer submitted: {final_answer}\n"
61
+ f"Verification: {summary['passed']}/{summary['total']} passed; "
62
+ f"reward={summary['reward']}"
63
+ )
64
+ if not command.strip():
65
+ return "Error: command or final_answer is required."
66
+ result = self._run_shell_command(command)
67
+ self._state.commands.append(result)
68
+ return result.output
69
+
70
+ super().__init__(mcp)
71
+
72
+ def reset(
73
+ self,
74
+ seed: Optional[int] = None,
75
+ episode_id: Optional[str] = None,
76
+ **kwargs: Any,
77
+ ) -> Observation:
78
+ """Create a fresh HF Sandbox job and run optional setup commands."""
79
+ if self._sandbox:
80
+ self._sandbox.kill()
81
+ self._sandbox = None
82
+
83
+ self._state = TerminusState(
84
+ episode_id=episode_id or str(uuid4()),
85
+ step_count=0,
86
+ )
87
+ try:
88
+ self._sandbox = HFSandbox(
89
+ image=kwargs.get("sandbox_image") or kwargs.get("hf_sandbox_image"),
90
+ flavor=kwargs.get("sandbox_flavor") or kwargs.get("hf_sandbox_flavor"),
91
+ timeout=kwargs.get("sandbox_timeout") or kwargs.get("hf_sandbox_timeout"),
92
+ forward_hf_token=kwargs.get("forward_hf_token"),
93
+ )
94
+ except Exception as exc: # noqa: BLE001
95
+ return Observation(
96
+ done=True,
97
+ reward=None,
98
+ metadata={
99
+ "status": "error",
100
+ "error": f"failed to create HF sandbox: {type(exc).__name__}: {exc}",
101
+ },
102
+ )
103
+
104
+ self._state.sandbox_id = self._sandbox.sandbox_id
105
+ setup_commands = _coerce_commands(
106
+ kwargs.get("setup", kwargs.get("setup_scripts", []))
107
+ )
108
+ verify_commands = _coerce_commands(
109
+ kwargs.get("verify", kwargs.get("verify_scripts", []))
110
+ )
111
+ self._state.verify_commands = verify_commands
112
+
113
+ self._sandbox.run_shell("mkdir -p /home/user/logs/verifier")
114
+ if setup_commands:
115
+ setup_results = self._run_shell_commands(setup_commands)
116
+ self._state.setup_results = setup_results
117
+ failed = [result for result in setup_results if not result.success]
118
+ if failed:
119
+ return Observation(
120
+ done=True,
121
+ reward=None,
122
+ metadata={
123
+ "status": "error",
124
+ "sandbox_id": self._state.sandbox_id,
125
+ "message": "Setup command failed.",
126
+ "setup_results": [
127
+ result.model_dump() for result in setup_results
128
+ ],
129
+ },
130
+ )
131
+
132
+ msg = "Terminus environment ready. Use terminal(command=...) to work."
133
+ if setup_commands:
134
+ msg += f" Setup commands run: {len(setup_commands)}."
135
+ if verify_commands:
136
+ msg += f" Verify commands registered: {len(verify_commands)}."
137
+ return Observation(
138
+ done=False,
139
+ reward=None,
140
+ metadata={
141
+ "status": "ready",
142
+ "sandbox_id": self._state.sandbox_id,
143
+ "message": msg,
144
+ "setup_results": [
145
+ result.model_dump() for result in self._state.setup_results
146
+ ],
147
+ "verify_commands": verify_commands,
148
+ },
149
+ )
150
+
151
+ def _step_impl(
152
+ self,
153
+ action: Action,
154
+ timeout_s: Optional[float] = None,
155
+ **_: Any,
156
+ ) -> Observation:
157
+ return Observation(
158
+ done=False,
159
+ reward=None,
160
+ metadata={
161
+ "error": (
162
+ f"Unknown action type: {type(action).__name__}. "
163
+ "Use ListToolsAction or CallToolAction for MCP interactions."
164
+ )
165
+ },
166
+ )
167
+
168
+ def step(
169
+ self,
170
+ action: Action,
171
+ timeout_s: Optional[float] = None,
172
+ **kwargs: Any,
173
+ ) -> Observation:
174
+ self._state.step_count += 1
175
+ obs = super().step(action, timeout_s=timeout_s, **kwargs)
176
+ if self._state.submitted_answer is not None and self._state.last_reward is not None:
177
+ obs.done = True
178
+ obs.reward = self._state.last_reward
179
+ elif obs.reward is None:
180
+ obs.reward = 0.0
181
+ return obs
182
+
183
+ async def step_async(
184
+ self,
185
+ action: Action,
186
+ timeout_s: Optional[float] = None,
187
+ **kwargs: Any,
188
+ ) -> Observation:
189
+ self._state.step_count += 1
190
+ obs = await super().step_async(action, timeout_s=timeout_s, **kwargs)
191
+ if self._state.submitted_answer is not None and self._state.last_reward is not None:
192
+ obs.done = True
193
+ obs.reward = self._state.last_reward
194
+ elif obs.reward is None:
195
+ obs.reward = 0.0
196
+ return obs
197
+
198
+ @property
199
+ def state(self) -> TerminusState:
200
+ return self._state
201
+
202
+ def close(self) -> None:
203
+ if self._sandbox:
204
+ self._sandbox.kill()
205
+ self._sandbox = None
206
+
207
+ def _run_shell_commands(self, commands: Iterable[str]) -> list[CommandResult]:
208
+ return [self._run_shell_command(command) for command in commands]
209
+
210
+ def _run_shell_command(self, command: str) -> CommandResult:
211
+ result = self._sandbox.run_shell(command)
212
+ output = _format_for_llm(result)
213
+ return CommandResult(
214
+ command=command,
215
+ output=output,
216
+ error=result.error,
217
+ success=result.success,
218
+ )
219
+
220
+ def _run_verify_commands(self) -> dict[str, Any]:
221
+ if not self._sandbox:
222
+ return {"passed": 0, "total": 0, "reward": None}
223
+
224
+ self._sandbox.run_shell("mkdir -p /home/user/logs/verifier")
225
+ verify_results = self._run_shell_commands(self._state.verify_commands)
226
+ self._state.verify_results = verify_results
227
+ passed = sum(1 for result in verify_results if result.success)
228
+ total = len(verify_results)
229
+ reward = _read_reward_override(self._sandbox)
230
+ if reward is None and total:
231
+ reward = passed / total
232
+ self._state.last_reward = reward
233
+ return {"passed": passed, "total": total, "reward": reward}
234
+
235
+
236
+ def _coerce_commands(value: Any) -> list[str]:
237
+ if value is None:
238
+ return []
239
+ if isinstance(value, str):
240
+ return [value] if value.strip() else []
241
+ return [str(item) for item in value if str(item).strip()]
242
+
243
+
244
+ def _format_for_llm(result) -> str:
245
+ parts = []
246
+ if result.stdout:
247
+ parts.append(result.stdout.strip())
248
+ if result.stderr:
249
+ parts.append(result.stderr.strip())
250
+ if result.error:
251
+ parts.append(f"ERROR:\n{result.error}")
252
+ return "\n".join(parts) if parts else "(no output)"
253
+
254
+
255
+ def _read_reward_override(sandbox: HFSandbox) -> Optional[float]:
256
+ result = sandbox.run_shell(f"cat {REWARD_FILE} 2>/dev/null || true")
257
+ raw = (result.stdout or "").strip()
258
+ if not raw:
259
+ return None
260
+ try:
261
+ return float(raw)
262
+ except ValueError:
263
+ return None
uv.lock ADDED
The diff for this file is too large to render. See raw diff