Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
run: |
uv sync --all-extras
uv pip install pytest pytest-asyncio numpy nltk smolagents python-chess moonfish \
langdetect "math-verify[antlr4_13_2]" datasets open_spiel
langdetect "math-verify[antlr4_13_2]" datasets open_spiel "textarena==0.7.4"

- name: Run tests
run: |
Expand Down
63 changes: 58 additions & 5 deletions envs/textarena_env/server/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
from __future__ import annotations

import os
import random
import re
import subprocess
import sys
import threading
import urllib.request
from typing import Any, Dict, Iterable, List, Optional
from contextlib import contextmanager
from typing import Any, Dict, Iterable, Iterator, List, Optional
from uuid import uuid4

from openenv.core.env_server.interfaces import Environment
Expand Down Expand Up @@ -42,6 +45,9 @@
_TEXTARENA_IMPORT_ERROR: Exception | None = None
_NLTK_DOWNLOADED: bool = False

# Serialize TextArena RNG access; seeded sessions retain their own stream.
_RNG_LOCK = threading.Lock()

# TextArena's Wordle feedback, e.g. "You submitted [crane].\nFeedback:\nC R A N E\nG X Y X G".
_WORDLE_FEEDBACK = re.compile(
r"You submitted \[(\w+)\]\.\nFeedback:\n[^\n]*\n([GYX ]+)"
Expand Down Expand Up @@ -167,7 +173,8 @@ def __init__(
self.max_turns = max_turns
self._env_kwargs = env_kwargs or {}

self._ta_env = ta.make(env_id=env_id, **self._env_kwargs)
with _RNG_LOCK:
self._ta_env = ta.make(env_id=env_id, **self._env_kwargs)

self._state = TextArenaState(
env_id=env_id,
Expand All @@ -181,7 +188,8 @@ def __init__(
# Initialize environment state - TextArena envs require reset() to be called
# before step() can be used, as the internal state object isn't created until reset.
# This ensures the environment is always in a valid state after construction.
self._ta_env.reset(num_players=self.num_players)
self._rng_state: Optional[tuple] = None
self._reset_ta_env()

# ------------------------------------------------------------------
# Environment interface
Expand All @@ -204,7 +212,7 @@ def reset(
if hasattr(env, "full_observations"):
env.full_observations = {}

self._ta_env.reset(num_players=self.num_players)
self._reset_ta_env(seed)

for provider in self._reward_providers:
provider.reset()
Expand All @@ -227,7 +235,8 @@ def step(self, action: TextArenaAction) -> TextArenaObservation: # type: ignore
if not isinstance(action, TextArenaAction):
raise TypeError(f"Expected TextArenaAction, received {type(action)!r}")

done, info = self._ta_env.step(action.message)
with self._session_rng():
done, info = self._ta_env.step(action.message)

self._state.step_count += 1
self._state.turn = getattr(self._ta_env.state, "turn", self._state.turn + 1)
Expand Down Expand Up @@ -297,6 +306,50 @@ def state(self) -> TextArenaState:
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _reset_ta_env(self, seed: Optional[int] = None) -> None:
"""Reset the wrapped game without disturbing other sessions.

TextArena implements ``reset(seed=...)`` by calling ``random.seed`` on the
process-global RNG and then drawing the episode from it. The lock keeps
seed and draw atomic across concurrent sessions. A seeded reset keeps the
RNG state it produced on the session, so later ``step`` calls continue the
same stream, and restores the prior process state so a seeded reset does
not make unseeded sessions predictable.
"""
with _RNG_LOCK:
self._rng_state = None
if seed is None:
self._ta_env.reset(num_players=self.num_players)
return
process_state = random.getstate()
try:
self._ta_env.reset(num_players=self.num_players, seed=seed)
self._rng_state = random.getstate()
finally:
random.setstate(process_state)

@contextmanager
def _session_rng(self) -> Iterator[None]:
"""Run a wrapped call under this session's RNG stream.

Unseeded sessions draw from the process-global RNG as before. A seeded
session swaps its saved state in, runs the call, saves the advanced
state back, and restores the process state on exit, including when the
call raises, so equal seeds and equal actions give equal trajectories
regardless of what other sessions do in between.
"""
with _RNG_LOCK:
if self._rng_state is None:
yield
return
process_state = random.getstate()
random.setstate(self._rng_state)
try:
yield
finally:
self._rng_state = random.getstate()
random.setstate(process_state)

def _build_observation(self) -> TextArenaObservation:
player_id, messages = self._ta_env.get_observation()

Expand Down
Loading
Loading