openreward 0.1.159.dev2__tar.gz → 0.1.161.dev0__tar.gz

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 (126) hide show
  1. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/PKG-INFO +3 -1
  2. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/README.md +2 -0
  3. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/environments/client.py +1 -1
  4. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/errors.py +5 -1
  5. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/sandboxes/client.py +24 -53
  6. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/sandboxes/types.py +5 -0
  7. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/_sandbox_tools.py +63 -4
  8. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/environment.py +80 -13
  9. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/server.py +2 -1
  10. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/types.py +1 -1
  11. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/sandbox_env.py +1 -1
  12. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/backsearch.py +2 -2
  13. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/claude_code.py +7 -7
  14. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/cli.py +9 -9
  15. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/codex.py +1 -1
  16. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/gemini_cli.py +91 -75
  17. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/hermes.py +80 -64
  18. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/openclaw.py +78 -62
  19. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/web.py +2 -2
  20. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/PKG-INFO +3 -1
  21. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/SOURCES.txt +2 -0
  22. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/pyproject.toml +1 -1
  23. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_cli_toolset_root_and_timeout.py +0 -3
  24. openreward-0.1.161.dev0/tests/test_episode_finished.py +212 -0
  25. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_gemini_cli_toolset.py +5 -3
  26. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_sandbox_tool_shell_safety.py +0 -5
  27. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_sandbox_tools.py +5 -3
  28. openreward-0.1.161.dev0/tests/test_sandboxes_client_paths.py +173 -0
  29. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_session_toolset.py +6 -3
  30. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_terminal_tool.py +5 -3
  31. openreward-0.1.161.dev0/tests/test_tool_concurrency.py +231 -0
  32. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_toolsets.py +7 -4
  33. openreward-0.1.159.dev2/tests/test_sandboxes_client_paths.py +0 -158
  34. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/LICENSE +0 -0
  35. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/__init__.py +0 -0
  36. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/_update_check.py +0 -0
  37. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/_version.py +0 -0
  38. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/__init__.py +0 -0
  39. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/_session/__init__.py +0 -0
  40. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/_session/http.py +0 -0
  41. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/_session/ping.py +0 -0
  42. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/_session/session.py +0 -0
  43. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/environments/__init__.py +0 -0
  44. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/environments/types.py +0 -0
  45. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/__init__.py +0 -0
  46. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/background.py +0 -0
  47. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/rollout.py +0 -0
  48. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/__init__.py +0 -0
  49. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/ant.py +0 -0
  50. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/base.py +0 -0
  51. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/gdm.py +0 -0
  52. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/models.py +0 -0
  53. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/oai_completions.py +0 -0
  54. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/oai_responses.py +0 -0
  55. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/utils.py +0 -0
  56. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/sandboxes/__init__.py +0 -0
  57. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/sandboxes/secrets.py +0 -0
  58. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/chat_backends/__init__.py +0 -0
  59. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/chat_backends/base.py +0 -0
  60. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/chat_backends/openai.py +0 -0
  61. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/cli.py +0 -0
  62. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/client.py +0 -0
  63. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/__init__.py +0 -0
  64. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/_metrics.py +0 -0
  65. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/reconnect.py +0 -0
  66. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/session.py +0 -0
  67. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/toolset.py +0 -0
  68. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/utils.py +0 -0
  69. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/http_client.py +0 -0
  70. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/__init__.py +0 -0
  71. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/_render.py +0 -0
  72. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/group.py +0 -0
  73. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/groupwise.py +0 -0
  74. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/pairwise.py +0 -0
  75. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/types.py +0 -0
  76. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/log_utils.py +0 -0
  77. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/models.py +0 -0
  78. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/search_backends/__init__.py +0 -0
  79. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/search_backends/backsearch.py +0 -0
  80. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/search_backends/base.py +0 -0
  81. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/search_backends/tavily.py +0 -0
  82. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/__init__.py +0 -0
  83. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/Dockerfile +0 -0
  84. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/__init__.py +0 -0
  85. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/requirements.txt +0 -0
  86. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/requirements.txt.tmpl +0 -0
  87. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/server.py.tmpl +0 -0
  88. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/Dockerfile +0 -0
  89. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/__init__.py +0 -0
  90. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/requirements.txt +0 -0
  91. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/server.py +0 -0
  92. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/tools/__init__.py +0 -0
  93. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/tools/search.py +0 -0
  94. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/tools/web.py +0 -0
  95. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/__init__.py +0 -0
  96. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/_web_common.py +0 -0
  97. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/excel.py +0 -0
  98. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/pdf.py +0 -0
  99. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/powerpoint.py +0 -0
  100. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/word.py +0 -0
  101. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/web_service.py +0 -0
  102. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/dependency_links.txt +0 -0
  103. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/entry_points.txt +0 -0
  104. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/requires.txt +0 -0
  105. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/top_level.txt +0 -0
  106. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/setup.cfg +0 -0
  107. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_chat_backends.py +0 -0
  108. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_cli_toolset_todo_write.py +0 -0
  109. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_env_server_retry.py +0 -0
  110. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_environment.py +0 -0
  111. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_errors.py +0 -0
  112. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_excel_toolset.py +0 -0
  113. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_judging.py +0 -0
  114. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_log_utils.py +0 -0
  115. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_messages.py +0 -0
  116. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_metrics.py +0 -0
  117. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_rich_messages.py +0 -0
  118. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_rollout_info.py +0 -0
  119. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_sandbox_secrets.py +0 -0
  120. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_sanitise.py +0 -0
  121. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_score_group.py +0 -0
  122. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_search_backends.py +0 -0
  123. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_session.py +0 -0
  124. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_tool_conversion.py +0 -0
  125. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_toolset_shell_safety.py +0 -0
  126. {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreward
3
- Version: 0.1.159.dev2
3
+ Version: 0.1.161.dev0
4
4
  Summary: Python SDK for the OpenReward platform.
5
5
  Author-email: GR Inc <hello@gr.inc>
6
6
  License-Expression: MIT
@@ -85,6 +85,8 @@ An `Environment` subclass defines a benchmark or task distribution. Implement th
85
85
 
86
86
  Actions are defined as `async` methods decorated with `@tool`. Each tool receives a Pydantic model as input and returns a `ToolOutput`.
87
87
 
88
+ Calls to the same tool run one at a time per session (different tools still overlap), so parallel calls (e.g. from a multi-agent harness) cannot race a grader or submit twice; once a call returns `finished=True`, queued calls are refused without running. Mark tools that never grant reward with `@tool(concurrent=True)` to let them run in parallel. The built-in shell, file and web toolsets already do.
89
+
88
90
  ### ToolOutput
89
91
 
90
92
  Every tool returns a `ToolOutput` containing:
@@ -39,6 +39,8 @@ An `Environment` subclass defines a benchmark or task distribution. Implement th
39
39
 
40
40
  Actions are defined as `async` methods decorated with `@tool`. Each tool receives a Pydantic model as input and returns a `ToolOutput`.
41
41
 
42
+ Calls to the same tool run one at a time per session (different tools still overlap), so parallel calls (e.g. from a multi-agent harness) cannot race a grader or submit twice; once a call returns `finished=True`, queued calls are refused without running. Mark tools that never grant reward with `@tool(concurrent=True)` to let them run in parallel. The built-in shell, file and web toolsets already do.
43
+
42
44
  ### ToolOutput
43
45
 
44
46
  Every tool returns a `ToolOutput` containing:
@@ -635,7 +635,7 @@ def _validate_toolset_name(toolset: Optional[str]) -> Optional[str]:
635
635
  return toolset
636
636
 
637
637
 
638
- _VALID_REASONS: set[str] = {"not_found", "name_collision", "input_validation", "bad_input_shape"}
638
+ _VALID_REASONS: set[str] = {"not_found", "name_collision", "input_validation", "bad_input_shape", "episode_finished"}
639
639
 
640
640
 
641
641
  def _infer_invalid_reason(error: str) -> ToolCallErrorReason:
@@ -88,11 +88,15 @@ ToolCallErrorReason = Literal[
88
88
  "name_collision",
89
89
  "input_validation",
90
90
  "bad_input_shape",
91
+ # The session's episode already finished (a tool returned
92
+ # finished=True); the server refuses every later call.
93
+ "episode_finished",
91
94
  ]
92
95
 
93
96
 
94
97
  class ToolCallError(OpenRewardError):
95
- """Tool call was malformed — won't succeed by retrying the same args.
98
+ """Tool call was malformed, or the episode already finished — won't
99
+ succeed by retrying the same args.
96
100
 
97
101
  The ``reason`` discriminates between the specific malformation, so the
98
102
  caller can phrase the feedback to the model appropriately.
@@ -1,14 +1,14 @@
1
1
  import asyncio
2
2
  import base64
3
3
  import re
4
+ import shlex
4
5
  import threading
5
6
  from pathlib import Path
6
7
  from typing import ClassVar, Mapping, Optional, Union
7
8
 
8
9
  import aiohttp
9
10
  from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt
10
- from openreward._version import USER_AGENT
11
- from openreward.api._session.http import _raise_for_status_with_auth, request_retryable, resumable_sse
11
+ from openreward.api._session.http import request_retryable, resumable_sse
12
12
  from openreward.api._session.session import BaseAsyncSession, SessionKind, SessionTerminatedError
13
13
  from openreward.api.sandboxes.secrets import build_secrets_header, augment_secrets_with_api_key
14
14
  from openreward.api.sandboxes.types import PodTerminatedError, RunResult, SandboxSettings
@@ -155,58 +155,37 @@ class AsyncSandboxesAPI(BaseAsyncSession):
155
155
  raise RuntimeError(f"Command failed: {cmd}\n{result.output}")
156
156
  return result.output
157
157
 
158
- # Files move through the exec-agent's /files endpoint as raw bytes rather
159
- # than through a shell command. The old `echo <base64> | base64 -d > path`
160
- # needed a base64 binary in the image (the retro-1999 image has none), put
161
- # a 13 MB command on the wire for a 10 MB file, and its exit code depended
162
- # on the image's shell. No shell also means the container path needs no
163
- # quoting: it travels as a query parameter and is used verbatim.
164
- MAX_UPLOAD_BYTES: ClassVar[int] = 10 * 1024 * 1024
165
-
166
- def _file_headers(self) -> dict[str, str]:
167
- return {"User-Agent": USER_AGENT, "X-API-Key": self.api_key, "X-Session-ID": self.sid}
168
-
169
- async def upload_bytes(self, data: bytes, container_path: str) -> None:
170
- """Write ``data`` to ``container_path`` in the container.
171
-
172
- The file is created with mode 0644, or truncated if it already exists,
173
- as a shell ``>`` redirection would. Parent directories are not created.
174
- """
175
- self._ensure_alive()
176
- self._ensure_started()
177
- if len(data) > self.MAX_UPLOAD_BYTES:
178
- raise ValueError(f"File is too large: {len(data)} bytes > {self.MAX_UPLOAD_BYTES} bytes")
179
- headers = {**self._file_headers(), "Content-Type": "application/octet-stream"}
180
- async with self.client.request(
181
- "PUT", "/files", params={"path": container_path}, data=data, headers=headers
182
- ) as resp:
183
- await _raise_for_status_with_auth(resp)
184
-
185
158
  async def upload(self, local_path: Union[str, Path], container_path: str) -> None:
186
159
  """Upload a single file from local filesystem to the container."""
187
160
  self._ensure_alive()
188
161
  local_path = Path(local_path)
189
162
  if not local_path.exists():
190
163
  raise FileNotFoundError(f"Local file not found: {local_path}")
191
- size = local_path.stat().st_size
192
- if size > self.MAX_UPLOAD_BYTES:
193
- raise ValueError(f"File is too large: {size} bytes > {self.MAX_UPLOAD_BYTES} bytes")
194
- await self.upload_bytes(local_path.read_bytes(), container_path)
195
164
 
196
- async def download(self, container_path: str) -> bytes:
197
- """Download a single file from the container.
165
+ max_size = 10 * 1024 * 1024
166
+ if local_path.stat().st_size > max_size:
167
+ raise ValueError(f"File is too large: {local_path.stat().st_size} bytes > {max_size} bytes")
198
168
 
199
- Raises :class:`FileNotFoundError` if there is no such file.
200
- """
169
+ file_content = local_path.read_bytes()
170
+ encoded_content = base64.b64encode(file_content).decode('ascii')
171
+
172
+ # Quote the destination: unquoted, a path with a space writes to two
173
+ # places and one with `;` or `$VAR` runs/expands. The base64 payload is
174
+ # [A-Za-z0-9+/=], so it needs no quoting.
175
+ cmd = f"echo '{encoded_content}' | base64 -d > {shlex.quote(container_path)}"
176
+ await self.check_run(cmd, max_bytes=max_size)
177
+
178
+ async def download(self, container_path: str) -> bytes:
179
+ """Download a single file from the container."""
201
180
  self._ensure_alive()
202
- self._ensure_started()
203
- async with self.client.request(
204
- "GET", "/files", params={"path": container_path}, headers=self._file_headers()
205
- ) as resp:
206
- if resp.status == 404:
207
- raise FileNotFoundError(f"No such file in the container: {container_path}")
208
- await _raise_for_status_with_auth(resp)
209
- return await resp.read()
181
+ cmd = f"base64 {shlex.quote(container_path)}"
182
+ output = await self.check_run(cmd, max_bytes=None)
183
+
184
+ try:
185
+ file_content = base64.b64decode(output.encode('ascii'))
186
+ return file_content
187
+ except Exception as e:
188
+ raise RuntimeError(f"Failed to decode and write file: {e}")
210
189
 
211
190
  async def start(self) -> None:
212
191
  await self.__aenter__()
@@ -290,14 +269,6 @@ class SandboxesAPI:
290
269
  self._async.upload(local_path, container_path)
291
270
  )
292
271
 
293
- def upload_bytes(self, data: bytes, container_path: str) -> None:
294
- """Write bytes to a file in the container."""
295
- if self._async is None:
296
- raise RuntimeError("Sandbox not started. Call start() or use as context manager.")
297
- self._run(
298
- self._async.upload_bytes(data, container_path)
299
- )
300
-
301
272
  def download(self, container_path: str) -> bytes:
302
273
  """Download a single file from the container."""
303
274
  if self._async is None:
@@ -8,14 +8,19 @@ MachineSize = Literal[
8
8
  '1:1',
9
9
  '2:2',
10
10
  '4:4',
11
+ '8:8',
12
+ '16:16',
11
13
  '0.5:1',
12
14
  '1:2',
13
15
  '2:4',
14
16
  '4:8',
17
+ '8:16',
15
18
  '0.5:2',
16
19
  '1:4',
17
20
  '2:8',
18
21
  '4:16',
22
+ '8:32',
23
+ '16:64',
19
24
  'nvidia-l4'
20
25
  ]
21
26
 
@@ -15,8 +15,11 @@ Public exports:
15
15
  """
16
16
  from __future__ import annotations
17
17
 
18
+ import asyncio
19
+ import base64
18
20
  import os
19
21
  import shlex
22
+ import weakref
20
23
  from typing import Any, Dict, List, Optional, overload
21
24
 
22
25
  from ..api.errors import SessionTerminatedError, TransportError
@@ -40,6 +43,25 @@ def _tool_error(e: Exception, what: str) -> ToolOutput:
40
43
  )
41
44
 
42
45
 
46
+ # ── Per-file locks ──────────────────────────────────────────────────────────
47
+
48
+ # Keyed by sandbox (not toolset) so toolsets sharing one sandbox share locks.
49
+ _file_locks: "weakref.WeakKeyDictionary[Any, Dict[str, asyncio.Lock]]" = weakref.WeakKeyDictionary()
50
+ _file_locks_by_id: Dict[int, Dict[str, asyncio.Lock]] = {}
51
+
52
+
53
+ def file_lock(sandbox: Any, path: str) -> asyncio.Lock:
54
+ """Lock serialising read-modify-write of ``path`` in ``sandbox``.
55
+
56
+ Only guards calls that take it (the edit/write tools); ``bash`` is not covered.
57
+ """
58
+ try:
59
+ locks = _file_locks.setdefault(sandbox, {})
60
+ except TypeError:
61
+ locks = _file_locks_by_id.setdefault(id(sandbox), {})
62
+ return locks.setdefault(os.path.normpath(path), asyncio.Lock())
63
+
64
+
43
65
  # ── Public text helpers ─────────────────────────────────────────────────────
44
66
 
45
67
  async def download_text(sandbox: Any, path: str, encoding: str = "utf-8") -> str:
@@ -61,13 +83,15 @@ async def upload_text(
61
83
  ) -> None:
62
84
  """Write text content to a file in the sandbox.
63
85
 
64
- Goes through ``sandbox.upload_bytes`` (the exec-agent's ``/files``
65
- endpoint), so no shell and no ``base64`` binary are involved and the
66
- path needs no quoting. Parent directories are not created.
86
+ Uses ``check_run`` with a base64-encoded payload so binary-safe
87
+ transfer works without depending on ``sandbox.upload`` (which expects
88
+ a local-FS path, not in-memory bytes).
67
89
  """
68
90
  if ensure_trailing_newline and not content.endswith("\n"):
69
91
  content = content + "\n"
70
- await sandbox.upload_bytes(content.encode("utf-8"), path)
92
+ encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
93
+ # Quote the destination; the base64 payload is [A-Za-z0-9+/=] and needs none.
94
+ await sandbox.check_run(f"echo '{encoded}' | base64 -d > {shlex.quote(path)}")
71
95
 
72
96
 
73
97
  # ── Workspace rooting ───────────────────────────────────────────────────────
@@ -233,6 +257,17 @@ async def _write_impl(
233
257
  content: str,
234
258
  *,
235
259
  root: Optional[str],
260
+ ) -> ToolOutput:
261
+ async with file_lock(sandbox, _rooted(file_path, root)):
262
+ return await _write_unlocked(sandbox, file_path, content, root=root)
263
+
264
+
265
+ async def _write_unlocked(
266
+ sandbox: Any,
267
+ file_path: str,
268
+ content: str,
269
+ *,
270
+ root: Optional[str],
236
271
  ) -> ToolOutput:
237
272
  try:
238
273
  file_path = _rooted(file_path, root)
@@ -257,6 +292,19 @@ async def _edit_impl(
257
292
  replace_all: bool,
258
293
  *,
259
294
  root: Optional[str],
295
+ ) -> ToolOutput:
296
+ async with file_lock(sandbox, _rooted(file_path, root)):
297
+ return await _edit_unlocked(sandbox, file_path, old_string, new_string, replace_all, root=root)
298
+
299
+
300
+ async def _edit_unlocked(
301
+ sandbox: Any,
302
+ file_path: str,
303
+ old_string: str,
304
+ new_string: str,
305
+ replace_all: bool,
306
+ *,
307
+ root: Optional[str],
260
308
  ) -> ToolOutput:
261
309
  """Strict-uniqueness edit: errors if ``old_string`` is non-unique and ``replace_all=False``.
262
310
 
@@ -307,6 +355,17 @@ async def _multi_edit_impl(
307
355
  edits: List[Dict[str, Any]],
308
356
  *,
309
357
  root: Optional[str],
358
+ ) -> ToolOutput:
359
+ async with file_lock(sandbox, _rooted(file_path, root)):
360
+ return await _multi_edit_unlocked(sandbox, file_path, edits, root=root)
361
+
362
+
363
+ async def _multi_edit_unlocked(
364
+ sandbox: Any,
365
+ file_path: str,
366
+ edits: List[Dict[str, Any]],
367
+ *,
368
+ root: Optional[str],
310
369
  ) -> ToolOutput:
311
370
  """Apply a sequence of edits to a file in one round-trip.
312
371
 
@@ -18,23 +18,35 @@ T = TypeVar("T")
18
18
 
19
19
  logger = _get_logger("openreward.environments")
20
20
 
21
+ # A serialised call queued this long suggests a slow tool that should be concurrent=True.
22
+ _SLOW_LOCK_WAIT_S = 30.0
23
+
21
24
 
22
25
  @overload
23
26
  def tool(fn: Callable[..., Any]) -> Callable[..., Any]: ...
24
27
  @overload
25
- def tool(*, shared: bool = True) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
26
- def tool(fn: Optional[Callable[..., Any]] = None, *, shared: bool = True) -> Callable[..., Any]:
27
- if fn is None:
28
- # Called with arguments: @tool(shared=False)
29
- def wrapper(f: Callable[..., Any]) -> Callable[..., Any]:
30
- setattr(f, "_env_tool", True)
31
- setattr(f, "_env_tool_shared", shared)
32
- return f
33
- return wrapper
34
- # Called without arguments: @tool
35
- setattr(fn, "_env_tool", True)
36
- setattr(fn, "_env_tool_shared", True)
37
- return fn
28
+ def tool(*, shared: bool = True, concurrent: bool = False) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
29
+ def tool(
30
+ fn: Optional[Callable[..., Any]] = None, *, shared: bool = True, concurrent: bool = False
31
+ ) -> Callable[..., Any]:
32
+ """Declare a method as a tool.
33
+
34
+ By default calls to a tool run one at a time per session (one lock per
35
+ tool, so different tools still overlap), so parallel calls cannot race a
36
+ grader or an attempt counter, and a call queued behind one that returned
37
+ ``finished=True`` is refused without running. Tools sharing mutable state
38
+ need their own lock. Pass
39
+ ``concurrent=True`` only for tools that never grant reward (shell, file
40
+ reads, search); those skip the session lock. ``@terminal`` tools cannot
41
+ be concurrent.
42
+ """
43
+ def mark(f: Callable[..., Any]) -> Callable[..., Any]:
44
+ setattr(f, "_env_tool", True)
45
+ setattr(f, "_env_tool_shared", shared)
46
+ setattr(f, "_env_tool_concurrent", concurrent)
47
+ return f
48
+ # @tool(...) returns the marker; bare @tool applies it.
49
+ return mark if fn is None else mark(fn)
38
50
 
39
51
 
40
52
  def terminal(fn: Callable[..., Any]) -> Callable[..., Any]:
@@ -66,6 +78,10 @@ def terminal(fn: Callable[..., Any]) -> Callable[..., Any]:
66
78
  return fn
67
79
 
68
80
 
81
+ def _is_concurrent(fn: Callable[..., Any]) -> bool:
82
+ return bool(getattr(fn, "_env_tool_concurrent", False)) and not getattr(fn, "_env_tool_terminal", False)
83
+
84
+
69
85
  def _terminal_arg(name: str, fn: Callable[..., Any]) -> Optional[str]:
70
86
  """Return the single input field name of a terminal tool, or None.
71
87
 
@@ -110,6 +126,11 @@ def _find_terminal_tool(owner: Any, found: Optional[TerminalToolSpec] = None) ->
110
126
  f"@terminal tool {name!r} is not a valid tool. It must take a single "
111
127
  f"Pydantic model argument and return ToolOutput."
112
128
  )
129
+ if getattr(fn, "_env_tool_concurrent", False):
130
+ raise ValueError(
131
+ f"@terminal tool {name!r} is marked concurrent=True. A terminal "
132
+ f"tool grades the episode, so it must run serialised."
133
+ )
113
134
  if found is not None:
114
135
  raise ValueError(
115
136
  f"Multiple @terminal tools defined: {found.name!r} and {name!r}. "
@@ -140,6 +161,13 @@ class Environment(ABC):
140
161
  file.
141
162
  """
142
163
  toolsets: ClassVar[Sequence[type]] = ()
164
+ # Set once any tool returns finished=True. From then on the episode is
165
+ # over and `_invoke_tool_fn` refuses every call on this instance (one
166
+ # instance per session). A class-level default so subclasses that skip
167
+ # `super().__init__` still get the latch.
168
+ _episode_finished: bool = False
169
+ # Per-tool locks serialising non-concurrent calls; created lazily for the same reason.
170
+ _tool_locks: Optional[dict[str, asyncio.Lock]] = None
143
171
 
144
172
  def __init__(self, task_spec: JSONObject = {}, secrets: dict[str, str] = {}) -> None:
145
173
  self.task_spec = task_spec
@@ -490,6 +518,28 @@ class Environment(ABC):
490
518
  start: float,
491
519
  ) -> RunToolOutput:
492
520
  """Validate input against the tool's Pydantic model and invoke it."""
521
+ if self._episode_finished:
522
+ return self._episode_finished_error(name)
523
+ if _is_concurrent(fn):
524
+ return await self._run_tool_fn(name, fn, input, start)
525
+ if self._tool_locks is None:
526
+ self._tool_locks = {}
527
+ async with self._tool_locks.setdefault(name, asyncio.Lock()):
528
+ waited = time.monotonic() - start
529
+ if waited > _SLOW_LOCK_WAIT_S:
530
+ logger.warning("tool_call_waited_for_tool_lock", tool=name, waited_s=round(waited, 1))
531
+ # The call we queued behind may have ended the episode.
532
+ if self._episode_finished:
533
+ return self._episode_finished_error(name)
534
+ return await self._run_tool_fn(name, fn, input, start)
535
+
536
+ async def _run_tool_fn(
537
+ self,
538
+ name: str,
539
+ fn: Callable[..., Any],
540
+ input: JSONObject,
541
+ start: float,
542
+ ) -> RunToolOutput:
493
543
  _, hints, params = _introspect_tool(fn)
494
544
  try:
495
545
  if not params:
@@ -513,10 +563,27 @@ class Environment(ABC):
513
563
  if not isinstance(res, ToolOutput):
514
564
  raise TypeError(f"{name!r} returned {type(res).__name__}; expected ToolOutput")
515
565
 
566
+ # A concurrent call on this session finished the episode while this
567
+ # one ran: the first finished=True wins, so this result is discarded
568
+ # rather than handed back as a second grade.
569
+ if self._episode_finished:
570
+ return self._episode_finished_error(name)
571
+ if res.finished:
572
+ self._episode_finished = True
573
+
516
574
  duration_ms = (time.monotonic() - start) * 1000
517
575
  logger.info("tool_call_completed", tool=name, duration_ms=duration_ms)
518
576
  return RunToolOutput(RunToolSuccess(output=res))
519
577
 
578
+ @staticmethod
579
+ def _episode_finished_error(name: str) -> RunToolOutput:
580
+ logger.warning("tool_call_after_episode_finished", tool=name)
581
+ return RunToolOutput(RunToolError(
582
+ reason="episode_finished",
583
+ error=f"{name!r} was not run: the episode has finished (a tool "
584
+ f"returned finished=True), so no further tool calls are accepted.",
585
+ ))
586
+
520
587
  @classmethod
521
588
  def name(cls) -> str:
522
589
  return cls.__name__
@@ -631,7 +631,8 @@ class Server:
631
631
  raise
632
632
  if isinstance(res.root, RunToolError):
633
633
  # Framework populates reason for the three malformed-call
634
- # cases (not_found / name_collision / input_validation).
634
+ # cases (not_found / name_collision / input_validation) and
635
+ # for calls refused after the episode finished.
635
636
  status = res.root.reason or "input_validation"
636
637
  else:
637
638
  status = "success"
@@ -118,7 +118,7 @@ class RunToolSuccess(BaseModel, extra="forbid"):
118
118
  ok: Literal[True] = True
119
119
  output: ToolOutput
120
120
 
121
- RunToolErrorReason = Literal["not_found", "name_collision", "input_validation"]
121
+ RunToolErrorReason = Literal["not_found", "name_collision", "input_validation", "episode_finished"]
122
122
 
123
123
 
124
124
  class RunToolError(BaseModel, extra="forbid"):
@@ -44,7 +44,7 @@ class SandboxEnv(Environment):
44
44
  async def teardown(self) -> None:
45
45
  await self.sandbox.stop()
46
46
 
47
- @tool
47
+ @tool(concurrent=True)
48
48
  async def bash(self, params: BashParams) -> ToolOutput:
49
49
  """Executes a bash command in the environment."""
50
50
 
@@ -124,7 +124,7 @@ class BackSearchToolset(Toolset):
124
124
  """
125
125
  return resolve_as_of(self.env, self._pinned_as_of)
126
126
 
127
- @tool
127
+ @tool(concurrent=True)
128
128
  async def web_search(self, params: WebSearchParams) -> ToolOutput:
129
129
  result = await run_search(
130
130
  query=params.query,
@@ -135,7 +135,7 @@ class BackSearchToolset(Toolset):
135
135
  )
136
136
  return _to_tool_output(result)
137
137
 
138
- @tool
138
+ @tool(concurrent=True)
139
139
  async def web_fetch(self, params: WebFetchParams) -> ToolOutput:
140
140
  result = await run_fetch(
141
141
  url=params.url,
@@ -207,31 +207,31 @@ class ClaudeCodeToolset(Toolset):
207
207
  # root=None throughout: the tool prompt tells the agent to use absolute
208
208
  # paths and avoid `cd`, so paths arrive already resolved.
209
209
 
210
- @tool
210
+ @tool(concurrent=True)
211
211
  async def bash(self, params: BashParams) -> ToolOutput:
212
212
  return await _bash_impl(self.sandbox, params.command, root=None)
213
213
 
214
- @tool
214
+ @tool(concurrent=True)
215
215
  async def glob(self, params: GlobParams) -> ToolOutput:
216
216
  return await _glob_impl(self.sandbox, params.pattern, params.path, root=None)
217
217
 
218
- @tool
218
+ @tool(concurrent=True)
219
219
  async def grep(self, params: GrepParams) -> ToolOutput:
220
220
  return await _grep_impl(
221
221
  self.sandbox, params.pattern, params.path, params.glob, root=None
222
222
  )
223
223
 
224
- @tool
224
+ @tool(concurrent=True)
225
225
  async def read(self, params: ReadParams) -> ToolOutput:
226
226
  return await _read_impl(
227
227
  self.sandbox, params.file_path, params.offset, params.limit, root=None
228
228
  )
229
229
 
230
- @tool
230
+ @tool(concurrent=True)
231
231
  async def write(self, params: WriteParams) -> ToolOutput:
232
232
  return await _write_impl(self.sandbox, params.file_path, params.content, root=None)
233
233
 
234
- @tool
234
+ @tool(concurrent=True)
235
235
  async def edit(self, params: EditParams) -> ToolOutput:
236
236
  return await _edit_impl(
237
237
  self.sandbox,
@@ -242,7 +242,7 @@ class ClaudeCodeToolset(Toolset):
242
242
  root=None,
243
243
  )
244
244
 
245
- @tool
245
+ @tool(concurrent=True)
246
246
  def todo_write(self, params: TodoWriteParams) -> ToolOutput:
247
247
  try:
248
248
  self.todos = params.todos
@@ -141,45 +141,45 @@ class CLIToolset(Toolset):
141
141
  root = getattr(env, "workspace_root", None) or type(self).root
142
142
  self.root = root
143
143
 
144
- @tool
144
+ @tool(concurrent=True)
145
145
  async def bash(self, params: BashParams) -> ToolOutput:
146
146
  """Execute a bash command in the sandbox."""
147
147
  return await _bash_impl(
148
148
  self.sandbox, params.command, params.timeout, root=self.root
149
149
  )
150
150
 
151
- @tool
151
+ @tool(concurrent=True)
152
152
  async def glob(self, params: GlobParams) -> ToolOutput:
153
153
  """Find files matching a glob pattern."""
154
154
  return await _glob_impl(self.sandbox, params.pattern, params.path, root=self.root)
155
155
 
156
- @tool
156
+ @tool(concurrent=True)
157
157
  async def grep(self, params: GrepParams) -> ToolOutput:
158
158
  """Recursively search file contents for a pattern."""
159
159
  return await _grep_impl(
160
160
  self.sandbox, params.pattern, params.path, params.include, root=self.root
161
161
  )
162
162
 
163
- @tool
163
+ @tool(concurrent=True)
164
164
  async def ls(self, params: LSParams) -> ToolOutput:
165
165
  """List directory contents (``ls -la``)."""
166
166
  return await _ls_impl(self.sandbox, params.path, root=self.root)
167
167
 
168
- @tool
168
+ @tool(concurrent=True)
169
169
  async def read(self, params: ReadParams) -> ToolOutput:
170
170
  """Read a file with optional line-range slicing; output has ``cat -n`` line numbers."""
171
171
  return await _read_impl(
172
172
  self.sandbox, params.file_path, params.offset, params.limit, root=self.root
173
173
  )
174
174
 
175
- @tool
175
+ @tool(concurrent=True)
176
176
  async def write(self, params: WriteParams) -> ToolOutput:
177
177
  """Write content to a file, creating parent directories as needed."""
178
178
  return await _write_impl(
179
179
  self.sandbox, params.file_path, params.content, root=self.root
180
180
  )
181
181
 
182
- @tool
182
+ @tool(concurrent=True)
183
183
  async def edit(self, params: EditParams) -> ToolOutput:
184
184
  """Exact-string replace in a file.
185
185
 
@@ -195,14 +195,14 @@ class CLIToolset(Toolset):
195
195
  root=self.root,
196
196
  )
197
197
 
198
- @tool
198
+ @tool(concurrent=True)
199
199
  async def multi_edit(self, params: MultiEditParams) -> ToolOutput:
200
200
  """Apply a sequence of edits to a single file atomically (in-memory)."""
201
201
  return await _multi_edit_impl(
202
202
  self.sandbox, params.file_path, params.edits, root=self.root
203
203
  )
204
204
 
205
- @tool
205
+ @tool(concurrent=True)
206
206
  async def todo_write(self, params: TodoWriteParams) -> ToolOutput:
207
207
  """Replace the session todo list and return a formatted view.
208
208
 
@@ -45,7 +45,7 @@ class CodexToolset(Toolset):
45
45
  def name(cls) -> str:
46
46
  return "codex"
47
47
 
48
- @tool
48
+ @tool(concurrent=True)
49
49
  async def bash(self, params: BashParams) -> ToolOutput:
50
50
  try:
51
51
  output, code = await self.sandbox.run(params.command.strip())