openenv_swebench: SWE-bench-style agentic RL adapter (mirrors TB2)#1705
openenv_swebench: SWE-bench-style agentic RL adapter (mirrors TB2)#1705Shi-Dong wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces an experimental SWE-bench-style GRPO training setup using HuggingFace's OpenEnv, adding documentation, data preparation scripts, a learning launcher, and an agentic adapter. Feedback on the changes suggests appending the user's UID to the shared temporary cache file path in swebench_agent_function.py to prevent permission conflicts on multi-user clusters, and refactoring exception checks in _is_retryable_env_error to use robust isinstance checks instead of string-based class name comparisons.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| # .git scan) and cache the answer, so every subsequent turn cds there cheaply. | ||
| # Setting OPENENV_TASK_WORKDIR forces a fixed dir and skips detection. | ||
| _TASK_WORKDIR = os.getenv("OPENENV_TASK_WORKDIR", "") | ||
| _REPO_ROOT_CACHE = "/tmp/.openenv_swebench_repo_root" |
There was a problem hiding this comment.
To prevent permission conflicts and directory sharing issues on multi-user clusters, shared temporary files should include the user's UID in their path. This ensures that different users running the adapter on the same host do not attempt to read or write to the same cache file.
| _REPO_ROOT_CACHE = "/tmp/.openenv_swebench_repo_root" | |
| _REPO_ROOT_CACHE = f"/tmp/.openenv_swebench_repo_root_{os.getuid()}" if hasattr(os, "getuid") else "/tmp/.openenv_swebench_repo_root" |
References
- When using shared temporary directories (such as /tmp) for caching or process-specific storage, append the user's UID (e.g., using os.getuid()) to the path to avoid permission conflicts and directory sharing issues on multi-user clusters.
| def _is_retryable_env_error(e: BaseException) -> bool: | ||
| """True when an env op failed only because no env slot was free (transient). | ||
|
|
||
| The docker-mode server caps concurrent envs; over that cap it either returns | ||
| CAPACITY_REACHED or closes the WebSocket cleanly (ConnectionClosedOK). Both | ||
| mean "retry once a slot frees up", not a genuine episode failure. Match the | ||
| close exceptions by class name so the adapter need not import websockets. | ||
| """ | ||
| if "CAPACITY_REACHED" in str(e): | ||
| return True | ||
| return type(e).__name__ in {"ConnectionClosedOK", "ConnectionClosedError", "ConnectionClosed"} |
There was a problem hiding this comment.
When identifying or filtering specific exception types, prefer using isinstance checks rather than parsing the exception's string representation or checking for class names within the exception string. Since websockets is a dependency of tbench2_env (which is required to run this code), we can safely import ConnectionClosed and perform an isinstance check.
| def _is_retryable_env_error(e: BaseException) -> bool: | |
| """True when an env op failed only because no env slot was free (transient). | |
| The docker-mode server caps concurrent envs; over that cap it either returns | |
| CAPACITY_REACHED or closes the WebSocket cleanly (ConnectionClosedOK). Both | |
| mean "retry once a slot frees up", not a genuine episode failure. Match the | |
| close exceptions by class name so the adapter need not import websockets. | |
| """ | |
| if "CAPACITY_REACHED" in str(e): | |
| return True | |
| return type(e).__name__ in {"ConnectionClosedOK", "ConnectionClosedError", "ConnectionClosed"} | |
| def _is_retryable_env_error(e: BaseException) -> bool: | |
| """True when an env op failed only because no env slot was free (transient). | |
| The docker-mode server caps concurrent envs; over that cap it either returns | |
| CAPACITY_REACHED or closes the WebSocket cleanly (ConnectionClosedOK). Both | |
| mean "retry once a slot frees up", not a genuine episode failure. | |
| """ | |
| if "CAPACITY_REACHED" in str(e): | |
| return True | |
| try: | |
| from websockets.exceptions import ConnectionClosed | |
| return isinstance(e, ConnectionClosed) | |
| except ImportError: | |
| return type(e).__name__ in {"ConnectionClosedOK", "ConnectionClosedError", "ConnectionClosed"} |
References
- When identifying or filtering specific exception types, prefer using isinstance checks rather than parsing the exception's string representation or checking for class names within the exception string.
SWE-bench images install the repo + pytest + runtime deps into a conda env (default "testbed"), not the base env the container boots into. tests/test.sh activates it for grading, but the agent's exec commands ran in the base env, where pytest is missing and importing the target package raises ModuleNotFoundError. The agent got no test feedback and its edits were never run in the graded environment, yielding uniform zero reward across all rollouts. Activate the env (mirroring test.sh) for every agent command via _apply_workdir, gated on the env existing so it stays a no-op on donor images without one. Configurable via OPENENV_CONDA_ENV (default "testbed"; "" disables).
The policy narrates with illustrative ```python planning snippets before emitting its one shell command, and GLM-4.7 sometimes uses its native <tool_call>...<arg_value>CMD</arg_value> format instead of a fenced block. The old parser grabbed the first fence of any language (executing planning python as a shell command) and fed raw <tool_call> XML to bash when no fence was present, producing a syntax-error storm: ~24% of all agent turns errored, so the model could never land a fix and every trajectory scored reward 0. Extract the command by preferring the last bash/sh/shell (or language-less) fence, skipping python blocks, and falling back to the <tool_call> arg. When no runnable command is present, nudge for the right format instead of executing the raw reply. Validated on 1501 real turns: garbage-to-bash drops 24% -> ~1%, 80% now yield a clean command.
The tbench2 env wraps every exec as bash -c '<cmd>' and docker-py shlex-splits that string. The eval command's single-quoted grep pattern collided with the wrapper quotes, leaving bash an unterminated $( -> parse error, empty output, no reward marker, and every sample dropped as "no verdict". Make the command single-quote-free (double-quote the grep pattern) so it survives the wrapper.
…tamper snapshot, black - Timeout no longer scored 0.0; dropped like any no-verdict episode (the cap spans infra phases, so a timeout can precede any verdict -> false negative). - cleanup() now scopes the process kill to the current euid and honors OPENENV_SKIP_CLEANUP=1; docstring states the disposable-pod assumption. - --num-rollout is now a real CLI flag (ScriptArgs field + Protocol), no longer a hardcoded 40 that the README documented but the CLI ignored. - Snapshot pristine grader tests at reset and grade from the snapshot, so mid-episode tampering with the staged tests can't reach the grader (not a hard boundary vs a root agent; noted as such). - Clarify the rm-hack comment: effective/necessary in Daytona mode, no-op in docker mode (dir is host-side, not bind-mounted). - Reformat swebench_agent_function.py with black to fix the red CI check.
base_env_vars only derived four env vars from launcher flags, but the adapter reads six more straight from os.getenv (OPENENV_TASK_WORKDIR, OPENENV_CONDA_ENV, OPENENV_MESSAGE_TIMEOUT_S, OPENENV_SWEBENCH_TESTS_SRC, OPENENV_SWEBENCH_TESTS_SNAPSHOT, OPENENV_EVAL_CMD). Under MILES_SCRIPT_EXTERNAL_RAY=1 the rollout runs on a remote cluster that does not inherit the submission shell, so those overrides silently reverted to defaults. Forward each one that is set (membership test so an intentional empty value still propagates).
Summary
Adds a self-contained SWE-bench-style agentic-RL adapter under
examples/experimental/openenv_swebench/, mirroring the existingTerminal-Bench-2 adapter in
examples/experimental/openenv/(added in #1487).It trains GLM-4.7-Flash with GRPO on SWE-Rebench-V2 "donor" tasks through the
unmodified OpenEnv env server — no changes to
examples/experimental/openenvand no OpenEnv patch/vendor.
New files (parallel to the TB2 set):
swebench_agent_function.py— the multi-turn adapter (the only file with realswebench-specific logic).
swebench_generate.py— reward_func (reads backmetadata["reward"]).swebench_launch_common.py— shared launch helpers.run-openenv-swebench.py— GLM-4.7-Flash launcher.make_swebench_data.py— prompt-data builder over a task pool.README.md— setup, incl. the offline image data-prep step.Why a new directory instead of generalizing the TB2 adapter
Per review preference, the TB2 adapter stays untouched. SWE-bench tasks differ
from TB2 on two axes, both handled entirely inside the new adapter:
/app; SWE-Rebench-V2 donorimages put the repo at varying paths (
/app,/testbed,/<repo>, …). Theadapter auto-detects the repo root once (the same probe
tests/test.shuses),caches it, and cds the agent there.
OPENENV_TASK_WORKDIRforces a fixed dir./logs/verifier/reward.txt; itprints
RESULT: PASSED/RESULT: FAILEDand exits 0/1. The adapter runs it viaa plain
execstep and derives the reward from the verdict line. No verdict(verifier crashed before scoring) → sample dropped, same as the TB2 adapter.
The env-client plumbing, capacity/retry handling, session-server prefix-echo,
wall-clock cap, and sandbox rm-hack are shared verbatim with the TB2 adapter.
Out of scope (not code)
The OpenEnv env server only pulls
task.toml [environment].docker_image.Donor tasks ship an
environment/Dockerfileand nodocker_image, so before arun each task needs its image built/pushed and
docker_imagerecorded — offlinedata prep documented in the README, no code change here.
Test plan
python -m py_compileon all five modules (done locally, clean).bash -non the generated eval + workdir shell snippets (done locally, clean).make_swebench_data.py --n 8,serve the env,
run-openenv-swebench.py --mode debug_rollout_only, confirmnon-zero reward variance across a task where the base policy sometimes passes.