openreward 0.1.161.dev0__tar.gz → 0.1.161.dev1__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.
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/PKG-INFO +1 -1
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/_sandbox_tools.py +19 -4
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/environment.py +103 -18
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/cli.py +21 -2
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/PKG-INFO +1 -1
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/pyproject.toml +1 -1
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_cli_toolset_root_and_timeout.py +50 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_tool_concurrency.py +126 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/LICENSE +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/README.md +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/_update_check.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/_version.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/_session/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/_session/http.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/_session/ping.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/_session/session.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/environments/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/environments/client.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/environments/types.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/errors.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/background.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/rollout.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/ant.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/base.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/gdm.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/models.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/oai_completions.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/oai_responses.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/utils.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/sandboxes/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/sandboxes/client.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/sandboxes/secrets.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/sandboxes/types.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/chat_backends/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/chat_backends/base.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/chat_backends/openai.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/cli.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/client.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/_metrics.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/reconnect.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/server.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/session.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/toolset.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/types.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/utils.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/http_client.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/_render.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/group.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/groupwise.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/pairwise.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/types.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/log_utils.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/models.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/backsearch.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/base.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/tavily.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/Dockerfile +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/requirements.txt +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/requirements.txt.tmpl +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/server.py.tmpl +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/Dockerfile +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/requirements.txt +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/sandbox_env.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/server.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/tools/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/tools/search.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/tools/web.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/__init__.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/_web_common.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/backsearch.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/claude_code.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/codex.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/excel.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/gemini_cli.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/hermes.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/openclaw.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/pdf.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/powerpoint.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/web.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/word.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/web_service.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/SOURCES.txt +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/dependency_links.txt +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/entry_points.txt +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/requires.txt +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/top_level.txt +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/setup.cfg +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_chat_backends.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_cli_toolset_todo_write.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_env_server_retry.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_environment.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_episode_finished.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_errors.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_excel_toolset.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_gemini_cli_toolset.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_judging.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_log_utils.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_messages.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_metrics.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_rich_messages.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_rollout_info.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sandbox_secrets.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sandbox_tool_shell_safety.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sandbox_tools.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sandboxes_client_paths.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sanitise.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_score_group.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_search_backends.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_session.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_session_toolset.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_terminal_tool.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_tool_conversion.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_toolset_shell_safety.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_toolsets.py +0 -0
- {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_web_tools.py +0 -0
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/_sandbox_tools.py
RENAMED
|
@@ -137,14 +137,29 @@ async def _bash_impl(
|
|
|
137
137
|
timeout: Optional[float] = None,
|
|
138
138
|
*,
|
|
139
139
|
root: Optional[str],
|
|
140
|
+
max_bytes: Optional[int] = None,
|
|
140
141
|
) -> ToolOutput:
|
|
141
142
|
# Omit rather than forward None, so sandbox.run applies its own default.
|
|
142
|
-
kwargs = {} if timeout is None else {"timeout": timeout}
|
|
143
|
+
kwargs: Dict[str, Any] = {} if timeout is None else {"timeout": timeout}
|
|
144
|
+
if max_bytes is not None:
|
|
145
|
+
kwargs["max_bytes"] = max_bytes
|
|
143
146
|
try:
|
|
144
|
-
|
|
147
|
+
result = await sandbox.run(_rooted_command(command.strip(), root), **kwargs)
|
|
148
|
+
output, code = result
|
|
149
|
+
# A killed command otherwise reads as one that printed nothing and exited 124.
|
|
150
|
+
notes = []
|
|
151
|
+
metadata: Dict[str, Any] = {"output": output, "exit_code": code}
|
|
152
|
+
if getattr(result, "truncated", False):
|
|
153
|
+
notes.append("output exceeded the limit and was cut off")
|
|
154
|
+
metadata["truncated"] = True
|
|
155
|
+
if getattr(result, "timed_out", False):
|
|
156
|
+
limit = f"{timeout:g}s " if timeout is not None else ""
|
|
157
|
+
notes.append(f"command hit the {limit}time limit and was killed")
|
|
158
|
+
metadata["timed_out"] = True
|
|
159
|
+
suffix = f"\n\n[{'; '.join(notes)}]" if notes else ""
|
|
145
160
|
return ToolOutput(
|
|
146
|
-
blocks=[TextBlock(text=f"{output}\n\n(exit {code})")],
|
|
147
|
-
metadata=
|
|
161
|
+
blocks=[TextBlock(text=f"{output}\n\n(exit {code}){suffix}")],
|
|
162
|
+
metadata=metadata,
|
|
148
163
|
finished=False,
|
|
149
164
|
)
|
|
150
165
|
except Exception as e:
|
|
@@ -3,7 +3,8 @@ import functools
|
|
|
3
3
|
import inspect
|
|
4
4
|
import time
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
-
from
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, ClassVar, Optional, Sequence, TypeVar, Union, get_type_hints, overload
|
|
7
8
|
|
|
8
9
|
from pydantic import BaseModel, ValidationError
|
|
9
10
|
|
|
@@ -25,9 +26,15 @@ _SLOW_LOCK_WAIT_S = 30.0
|
|
|
25
26
|
@overload
|
|
26
27
|
def tool(fn: Callable[..., Any]) -> Callable[..., Any]: ...
|
|
27
28
|
@overload
|
|
28
|
-
def tool(*, shared: bool = True, concurrent: bool = False) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
|
|
29
29
|
def tool(
|
|
30
|
-
|
|
30
|
+
*, shared: bool = True, concurrent: bool = False, exclusive: bool = False
|
|
31
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
|
|
32
|
+
def tool(
|
|
33
|
+
fn: Optional[Callable[..., Any]] = None,
|
|
34
|
+
*,
|
|
35
|
+
shared: bool = True,
|
|
36
|
+
concurrent: bool = False,
|
|
37
|
+
exclusive: bool = False,
|
|
31
38
|
) -> Callable[..., Any]:
|
|
32
39
|
"""Declare a method as a tool.
|
|
33
40
|
|
|
@@ -39,11 +46,17 @@ def tool(
|
|
|
39
46
|
``concurrent=True`` only for tools that never grant reward (shell, file
|
|
40
47
|
reads, search); those skip the session lock. ``@terminal`` tools cannot
|
|
41
48
|
be concurrent.
|
|
49
|
+
|
|
50
|
+
``exclusive=True`` is for a tool that ends the episode (a model-called
|
|
51
|
+
``submit``): it waits for every in-flight call on the session, concurrent
|
|
52
|
+
ones included, and holds new calls until it returns — so nothing can
|
|
53
|
+
change the state it grades. ``@terminal`` tools are always exclusive.
|
|
42
54
|
"""
|
|
43
55
|
def mark(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
44
56
|
setattr(f, "_env_tool", True)
|
|
45
57
|
setattr(f, "_env_tool_shared", shared)
|
|
46
58
|
setattr(f, "_env_tool_concurrent", concurrent)
|
|
59
|
+
setattr(f, "_env_tool_exclusive", exclusive)
|
|
47
60
|
return f
|
|
48
61
|
# @tool(...) returns the marker; bare @tool applies it.
|
|
49
62
|
return mark if fn is None else mark(fn)
|
|
@@ -78,8 +91,57 @@ def terminal(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
78
91
|
return fn
|
|
79
92
|
|
|
80
93
|
|
|
94
|
+
def _is_exclusive(fn: Callable[..., Any]) -> bool:
|
|
95
|
+
return bool(getattr(fn, "_env_tool_exclusive", False) or getattr(fn, "_env_tool_terminal", False))
|
|
96
|
+
|
|
97
|
+
|
|
81
98
|
def _is_concurrent(fn: Callable[..., Any]) -> bool:
|
|
82
|
-
return bool(getattr(fn, "_env_tool_concurrent", False)) and not
|
|
99
|
+
return bool(getattr(fn, "_env_tool_concurrent", False)) and not _is_exclusive(fn)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _ExclusiveGate:
|
|
103
|
+
"""Per-session readers-writer gate: tool calls share it, exclusive tools own it.
|
|
104
|
+
|
|
105
|
+
Writer-preferring, so a queued exclusive call is not starved by a stream of
|
|
106
|
+
concurrent ones. A tool that calls another tool from inside its body would
|
|
107
|
+
deadlock behind a queued exclusive call; tools don't do that.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._cond = asyncio.Condition()
|
|
112
|
+
self._active = 0
|
|
113
|
+
self._writing = False
|
|
114
|
+
self._writers_waiting = 0
|
|
115
|
+
|
|
116
|
+
@asynccontextmanager
|
|
117
|
+
async def shared(self) -> AsyncIterator[None]:
|
|
118
|
+
async with self._cond:
|
|
119
|
+
await self._cond.wait_for(lambda: not self._writing and not self._writers_waiting)
|
|
120
|
+
self._active += 1
|
|
121
|
+
try:
|
|
122
|
+
yield
|
|
123
|
+
finally:
|
|
124
|
+
async with self._cond:
|
|
125
|
+
self._active -= 1
|
|
126
|
+
self._cond.notify_all()
|
|
127
|
+
|
|
128
|
+
@asynccontextmanager
|
|
129
|
+
async def exclusive(self) -> AsyncIterator[None]:
|
|
130
|
+
async with self._cond:
|
|
131
|
+
self._writers_waiting += 1
|
|
132
|
+
try:
|
|
133
|
+
await self._cond.wait_for(lambda: not self._writing and self._active == 0)
|
|
134
|
+
finally:
|
|
135
|
+
self._writers_waiting -= 1
|
|
136
|
+
# A cancelled writer may have been what held readers back.
|
|
137
|
+
self._cond.notify_all()
|
|
138
|
+
self._writing = True
|
|
139
|
+
try:
|
|
140
|
+
yield
|
|
141
|
+
finally:
|
|
142
|
+
async with self._cond:
|
|
143
|
+
self._writing = False
|
|
144
|
+
self._cond.notify_all()
|
|
83
145
|
|
|
84
146
|
|
|
85
147
|
def _terminal_arg(name: str, fn: Callable[..., Any]) -> Optional[str]:
|
|
@@ -168,6 +230,8 @@ class Environment(ABC):
|
|
|
168
230
|
_episode_finished: bool = False
|
|
169
231
|
# Per-tool locks serialising non-concurrent calls; created lazily for the same reason.
|
|
170
232
|
_tool_locks: Optional[dict[str, asyncio.Lock]] = None
|
|
233
|
+
# Shared by every call, owned by exclusive ones; created lazily for the same reason.
|
|
234
|
+
_exclusive_gate: Optional[_ExclusiveGate] = None
|
|
171
235
|
|
|
172
236
|
def __init__(self, task_spec: JSONObject = {}, secrets: dict[str, str] = {}) -> None:
|
|
173
237
|
self.task_spec = task_spec
|
|
@@ -520,26 +584,47 @@ class Environment(ABC):
|
|
|
520
584
|
"""Validate input against the tool's Pydantic model and invoke it."""
|
|
521
585
|
if self._episode_finished:
|
|
522
586
|
return self._episode_finished_error(name)
|
|
587
|
+
if self._exclusive_gate is None:
|
|
588
|
+
self._exclusive_gate = _ExclusiveGate()
|
|
589
|
+
gate = self._exclusive_gate
|
|
590
|
+
if _is_exclusive(fn):
|
|
591
|
+
async with gate.exclusive():
|
|
592
|
+
return await self._run_after_wait(name, fn, input, start)
|
|
523
593
|
if _is_concurrent(fn):
|
|
524
|
-
|
|
594
|
+
async with gate.shared():
|
|
595
|
+
# Only an exclusive call can have held us, and it may have ended the episode.
|
|
596
|
+
if self._episode_finished:
|
|
597
|
+
return self._episode_finished_error(name)
|
|
598
|
+
return await self._run_tool_fn(name, fn, input)
|
|
525
599
|
if self._tool_locks is None:
|
|
526
600
|
self._tool_locks = {}
|
|
601
|
+
# Tool lock outside the gate, so calls queued on a tool don't hold off an exclusive one.
|
|
527
602
|
async with self._tool_locks.setdefault(name, asyncio.Lock()):
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
603
|
+
async with gate.shared():
|
|
604
|
+
return await self._run_after_wait(name, fn, input, start)
|
|
605
|
+
|
|
606
|
+
async def _run_after_wait(
|
|
607
|
+
self, name: str, fn: Callable[..., Any], input: JSONObject, start: float
|
|
608
|
+
) -> RunToolOutput:
|
|
609
|
+
waited = time.monotonic() - start
|
|
610
|
+
if waited > _SLOW_LOCK_WAIT_S:
|
|
611
|
+
logger.warning("tool_call_waited_for_tool_lock", tool=name, waited_s=round(waited, 1))
|
|
612
|
+
# The call we queued behind may have ended the episode.
|
|
613
|
+
if self._episode_finished:
|
|
614
|
+
return self._episode_finished_error(name, lock_wait_ms=waited * 1000)
|
|
615
|
+
return await self._run_tool_fn(name, fn, input, lock_wait_ms=waited * 1000)
|
|
535
616
|
|
|
536
617
|
async def _run_tool_fn(
|
|
537
618
|
self,
|
|
538
619
|
name: str,
|
|
539
620
|
fn: Callable[..., Any],
|
|
540
621
|
input: JSONObject,
|
|
541
|
-
|
|
622
|
+
lock_wait_ms: Optional[float] = None,
|
|
542
623
|
) -> RunToolOutput:
|
|
624
|
+
"""Run the tool; ``duration_ms`` excludes time queued on the tool lock (``lock_wait_ms``)."""
|
|
625
|
+
start = time.monotonic()
|
|
626
|
+
# Only serialised calls carry lock_wait_ms, so concurrent tools can be told apart.
|
|
627
|
+
timing = {} if lock_wait_ms is None else {"lock_wait_ms": lock_wait_ms}
|
|
543
628
|
_, hints, params = _introspect_tool(fn)
|
|
544
629
|
try:
|
|
545
630
|
if not params:
|
|
@@ -557,7 +642,7 @@ class Environment(ABC):
|
|
|
557
642
|
res = await run_user_callable(fn, inp)
|
|
558
643
|
except Exception:
|
|
559
644
|
duration_ms = (time.monotonic() - start) * 1000
|
|
560
|
-
logger.exception("tool_call_failed", tool=name, duration_ms=duration_ms)
|
|
645
|
+
logger.exception("tool_call_failed", tool=name, duration_ms=duration_ms, **timing)
|
|
561
646
|
raise
|
|
562
647
|
|
|
563
648
|
if not isinstance(res, ToolOutput):
|
|
@@ -567,17 +652,17 @@ class Environment(ABC):
|
|
|
567
652
|
# one ran: the first finished=True wins, so this result is discarded
|
|
568
653
|
# rather than handed back as a second grade.
|
|
569
654
|
if self._episode_finished:
|
|
570
|
-
return self._episode_finished_error(name)
|
|
655
|
+
return self._episode_finished_error(name, **timing)
|
|
571
656
|
if res.finished:
|
|
572
657
|
self._episode_finished = True
|
|
573
658
|
|
|
574
659
|
duration_ms = (time.monotonic() - start) * 1000
|
|
575
|
-
logger.info("tool_call_completed", tool=name, duration_ms=duration_ms)
|
|
660
|
+
logger.info("tool_call_completed", tool=name, duration_ms=duration_ms, **timing)
|
|
576
661
|
return RunToolOutput(RunToolSuccess(output=res))
|
|
577
662
|
|
|
578
663
|
@staticmethod
|
|
579
|
-
def _episode_finished_error(name: str) -> RunToolOutput:
|
|
580
|
-
logger.warning("tool_call_after_episode_finished", tool=name)
|
|
664
|
+
def _episode_finished_error(name: str, **timing: float) -> RunToolOutput:
|
|
665
|
+
logger.warning("tool_call_after_episode_finished", tool=name, **timing)
|
|
581
666
|
return RunToolOutput(RunToolError(
|
|
582
667
|
reason="episode_finished",
|
|
583
668
|
error=f"{name!r} was not run: the episode has finished (a tool "
|
|
@@ -21,7 +21,7 @@ Pydantic param shapes and ``@tool``-decorated wrappers.
|
|
|
21
21
|
"""
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
-
from typing import Any, Dict, List, Literal, Optional
|
|
24
|
+
from typing import Any, ClassVar, Dict, List, Literal, Optional
|
|
25
25
|
|
|
26
26
|
from pydantic import BaseModel
|
|
27
27
|
|
|
@@ -119,12 +119,26 @@ class CLIToolset(Toolset):
|
|
|
119
119
|
``root`` resolution, first match wins: the ``root`` constructor argument
|
|
120
120
|
(for direct instantiation and tests), then ``env.workspace_root``, then the
|
|
121
121
|
class attribute (``None`` = unrooted).
|
|
122
|
+
|
|
123
|
+
**bash limits.** Subclass and set ``bash_timeout``, ``max_bash_timeout`` and
|
|
124
|
+
``bash_max_bytes`` to replace ``sandbox.run``'s defaults (300s, 50KB)::
|
|
125
|
+
|
|
126
|
+
class MyCLIToolset(CLIToolset):
|
|
127
|
+
bash_timeout = max_bash_timeout = 120
|
|
128
|
+
bash_max_bytes = 4_000_000
|
|
122
129
|
"""
|
|
123
130
|
|
|
124
131
|
#: Default workspace root; see the class docstring. Subclasses may override.
|
|
125
132
|
#: Not a ClassVar — __init__ overwrites it per instance with the resolved root.
|
|
126
133
|
root: Optional[str] = None
|
|
127
134
|
|
|
135
|
+
#: bash timeout (s) when the call sets none; None defers to sandbox.run's default.
|
|
136
|
+
bash_timeout: ClassVar[Optional[float]] = None
|
|
137
|
+
#: Cap (s) on any bash timeout, including one the agent passes; None = uncapped.
|
|
138
|
+
max_bash_timeout: ClassVar[Optional[float]] = None
|
|
139
|
+
#: sandbox.run max_bytes for bash; None defers to sandbox.run's default.
|
|
140
|
+
bash_max_bytes: ClassVar[Optional[int]] = None
|
|
141
|
+
|
|
128
142
|
@classmethod
|
|
129
143
|
def name(cls) -> str:
|
|
130
144
|
return "cli"
|
|
@@ -144,8 +158,13 @@ class CLIToolset(Toolset):
|
|
|
144
158
|
@tool(concurrent=True)
|
|
145
159
|
async def bash(self, params: BashParams) -> ToolOutput:
|
|
146
160
|
"""Execute a bash command in the sandbox."""
|
|
161
|
+
timeout = params.timeout if params.timeout is not None else self.bash_timeout
|
|
162
|
+
if self.max_bash_timeout is not None:
|
|
163
|
+
timeout = min(timeout if timeout is not None else self.max_bash_timeout,
|
|
164
|
+
self.max_bash_timeout)
|
|
147
165
|
return await _bash_impl(
|
|
148
|
-
self.sandbox, params.command,
|
|
166
|
+
self.sandbox, params.command, timeout, root=self.root,
|
|
167
|
+
max_bytes=self.bash_max_bytes,
|
|
149
168
|
)
|
|
150
169
|
|
|
151
170
|
@tool(concurrent=True)
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_cli_toolset_root_and_timeout.py
RENAMED
|
@@ -146,6 +146,56 @@ def test_bash_timeout_default_is_none():
|
|
|
146
146
|
assert BashParams(command="x").timeout is None
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
class LimitedToolset(CLIToolset):
|
|
150
|
+
bash_timeout = 120
|
|
151
|
+
max_bash_timeout = 120
|
|
152
|
+
bash_max_bytes = 4_000_000
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_class_limits_apply_when_the_call_sets_none():
|
|
157
|
+
sb = RecordingSandbox()
|
|
158
|
+
await LimitedToolset(FakeEnv(sb)).bash(BashParams(command="make"))
|
|
159
|
+
assert sb.calls[0][1] == {"timeout": 120, "max_bytes": 4_000_000}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.asyncio
|
|
163
|
+
async def test_agent_timeout_is_capped():
|
|
164
|
+
sb = RecordingSandbox()
|
|
165
|
+
ts = LimitedToolset(FakeEnv(sb))
|
|
166
|
+
await ts.bash(BashParams(command="sleep 1", timeout=9999))
|
|
167
|
+
await ts.bash(BashParams(command="sleep 1", timeout=5))
|
|
168
|
+
assert [kw["timeout"] for _, kw in sb.calls] == [120, 5]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@pytest.mark.asyncio
|
|
172
|
+
async def test_cap_alone_bounds_the_sandbox_default():
|
|
173
|
+
class CapOnly(CLIToolset):
|
|
174
|
+
max_bash_timeout = 60
|
|
175
|
+
|
|
176
|
+
sb = RecordingSandbox()
|
|
177
|
+
await CapOnly(FakeEnv(sb)).bash(BashParams(command="make"))
|
|
178
|
+
assert sb.calls[0][1] == {"timeout": 60}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class KilledSandbox(RecordingSandbox):
|
|
182
|
+
async def run(self, cmd: str, **kwargs) -> RunResult:
|
|
183
|
+
self.calls.append((cmd, kwargs))
|
|
184
|
+
return RunResult(output="partial", return_code=124, truncated=True, timed_out=True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_timeout_and_truncation_are_reported():
|
|
189
|
+
sb = KilledSandbox()
|
|
190
|
+
out = await LimitedToolset(FakeEnv(sb)).bash(BashParams(command="yes"))
|
|
191
|
+
assert out.blocks[0].text == (
|
|
192
|
+
"partial\n\n(exit 124)\n\n[output exceeded the limit and was cut off; "
|
|
193
|
+
"command hit the 120s time limit and was killed]"
|
|
194
|
+
)
|
|
195
|
+
assert out.metadata == {"output": "partial", "exit_code": 124,
|
|
196
|
+
"truncated": True, "timed_out": True}
|
|
197
|
+
|
|
198
|
+
|
|
149
199
|
# ── enforcement ──
|
|
150
200
|
|
|
151
201
|
def test_impls_refuse_to_run_unrooted_by_omission():
|
|
@@ -229,3 +229,129 @@ def test_file_locks_are_per_sandbox_per_file():
|
|
|
229
229
|
assert file_lock(a, "/w/f.txt") is file_lock(a, "/w/./f.txt")
|
|
230
230
|
assert file_lock(a, "/w/f.txt") is not file_lock(a, "/w/g.txt")
|
|
231
231
|
assert file_lock(a, "/w/f.txt") is not file_lock(b, "/w/f.txt")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.mark.asyncio
|
|
235
|
+
async def test_logged_duration_excludes_lock_wait(monkeypatch):
|
|
236
|
+
from openreward.environments import environment as environment_module
|
|
237
|
+
|
|
238
|
+
done: list[dict] = []
|
|
239
|
+
monkeypatch.setattr(
|
|
240
|
+
environment_module.logger, "info",
|
|
241
|
+
lambda event, **kw: done.append(kw) if event == "tool_call_completed" else None,
|
|
242
|
+
)
|
|
243
|
+
env = SubmitEnv()
|
|
244
|
+
await asyncio.gather(*(env._call_tool("check", {"answer": "5"}) for _ in range(2)))
|
|
245
|
+
await env._call_tool("probe", {})
|
|
246
|
+
first, second, probe = done
|
|
247
|
+
# Each check runs ~10ms; the second queues ~10ms behind the first.
|
|
248
|
+
assert first["lock_wait_ms"] < 5 and second["lock_wait_ms"] >= 8
|
|
249
|
+
assert all(8 <= e["duration_ms"] < 50 for e in (first, second))
|
|
250
|
+
assert "lock_wait_ms" not in probe
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ── exclusive tools ──
|
|
254
|
+
|
|
255
|
+
class GatedEnv(SubmitEnv):
|
|
256
|
+
"""A shell-like concurrent tool and an exclusive submit that records what overlapped it."""
|
|
257
|
+
|
|
258
|
+
def __init__(self, task_spec: JSONObject = {}, secrets: dict[str, str] = {}) -> None:
|
|
259
|
+
super().__init__(task_spec, secrets)
|
|
260
|
+
self.shells_running = 0
|
|
261
|
+
self.overlapped_submit = False
|
|
262
|
+
self.shells_done = 0
|
|
263
|
+
|
|
264
|
+
@tool(concurrent=True)
|
|
265
|
+
async def shell(self) -> ToolOutput:
|
|
266
|
+
self.shells_running += 1
|
|
267
|
+
await asyncio.sleep(0.05)
|
|
268
|
+
self.shells_running -= 1
|
|
269
|
+
self.shells_done += 1
|
|
270
|
+
return ToolOutput(blocks=[TextBlock(text="ok")])
|
|
271
|
+
|
|
272
|
+
@tool(exclusive=True)
|
|
273
|
+
async def final(self) -> ToolOutput:
|
|
274
|
+
self.overlapped_submit = self.shells_running > 0
|
|
275
|
+
await asyncio.sleep(0.05)
|
|
276
|
+
return ToolOutput(blocks=[TextBlock(text="graded")], reward=1.0, finished=True)
|
|
277
|
+
|
|
278
|
+
@tool(exclusive=True)
|
|
279
|
+
async def soft_final(self) -> ToolOutput:
|
|
280
|
+
"""Exclusive but leaves the episode open (e.g. a grader fault)."""
|
|
281
|
+
self.overlapped_submit = self.shells_running > 0
|
|
282
|
+
await asyncio.sleep(0.05)
|
|
283
|
+
return ToolOutput(blocks=[TextBlock(text="try again")])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@pytest.mark.asyncio
|
|
287
|
+
async def test_exclusive_waits_for_in_flight_concurrent_calls():
|
|
288
|
+
env = GatedEnv()
|
|
289
|
+
shells = [asyncio.create_task(env._call_tool("shell", {})) for _ in range(3)]
|
|
290
|
+
await asyncio.sleep(0.01)
|
|
291
|
+
res = await env._call_tool("final", {})
|
|
292
|
+
await asyncio.gather(*shells)
|
|
293
|
+
assert res.root.ok and not env.overlapped_submit
|
|
294
|
+
assert env.shells_done == 3
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@pytest.mark.asyncio
|
|
298
|
+
async def test_calls_arriving_during_exclusive_are_held_then_refused():
|
|
299
|
+
env = GatedEnv()
|
|
300
|
+
final = asyncio.create_task(env._call_tool("final", {}))
|
|
301
|
+
await asyncio.sleep(0.01)
|
|
302
|
+
late = await asyncio.gather(env._call_tool("shell", {}), env._call_tool("check", {"answer": "4"}))
|
|
303
|
+
assert (await final).root.ok
|
|
304
|
+
assert all(_refused(r) for r in late)
|
|
305
|
+
assert env.shells_done == 0 and env.attempts == 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pytest.mark.asyncio
|
|
309
|
+
async def test_held_calls_run_when_exclusive_leaves_episode_open():
|
|
310
|
+
env = GatedEnv()
|
|
311
|
+
soft = asyncio.create_task(env._call_tool("soft_final", {}))
|
|
312
|
+
await asyncio.sleep(0.01)
|
|
313
|
+
shell = await env._call_tool("shell", {})
|
|
314
|
+
assert (await soft).root.ok and shell.root.ok
|
|
315
|
+
assert env.shells_done == 1 and not env.overlapped_submit
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@pytest.mark.asyncio
|
|
319
|
+
async def test_queued_exclusive_is_not_starved_by_new_concurrent_calls():
|
|
320
|
+
env = GatedEnv()
|
|
321
|
+
first = asyncio.create_task(env._call_tool("shell", {}))
|
|
322
|
+
await asyncio.sleep(0.01)
|
|
323
|
+
final = asyncio.create_task(env._call_tool("final", {}))
|
|
324
|
+
await asyncio.sleep(0.01)
|
|
325
|
+
later = await env._call_tool("shell", {})
|
|
326
|
+
assert (await first).root.ok and (await final).root.ok
|
|
327
|
+
assert _refused(later)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@pytest.mark.asyncio
|
|
331
|
+
async def test_terminal_tool_is_exclusive():
|
|
332
|
+
class TerminalEnv(GatedEnv):
|
|
333
|
+
@terminal
|
|
334
|
+
@tool
|
|
335
|
+
async def answer(self, args: Answer) -> ToolOutput:
|
|
336
|
+
self.overlapped_submit = self.shells_running > 0
|
|
337
|
+
return ToolOutput(blocks=[TextBlock(text="graded")], reward=1.0, finished=True)
|
|
338
|
+
|
|
339
|
+
env = TerminalEnv()
|
|
340
|
+
shell = asyncio.create_task(env._call_tool("shell", {}))
|
|
341
|
+
await asyncio.sleep(0.01)
|
|
342
|
+
await env.call_terminal_tool("4")
|
|
343
|
+
await shell
|
|
344
|
+
assert not env.overlapped_submit
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.asyncio
|
|
348
|
+
async def test_cancelled_exclusive_releases_held_calls():
|
|
349
|
+
env = GatedEnv()
|
|
350
|
+
first = asyncio.create_task(env._call_tool("shell", {}))
|
|
351
|
+
await asyncio.sleep(0.01)
|
|
352
|
+
final = asyncio.create_task(env._call_tool("final", {}))
|
|
353
|
+
await asyncio.sleep(0.01)
|
|
354
|
+
final.cancel()
|
|
355
|
+
shell = await asyncio.wait_for(env._call_tool("shell", {}), timeout=1)
|
|
356
|
+
await first
|
|
357
|
+
assert shell.root.ok
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/__init__.py
RENAMED
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/ant.py
RENAMED
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/base.py
RENAMED
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/gdm.py
RENAMED
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/backsearch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/requirements.txt
RENAMED
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/requirements.txt.tmpl
RENAMED
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/server.py.tmpl
RENAMED
|
File without changes
|
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/__init__.py
RENAMED
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/requirements.txt
RENAMED
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/sandbox_env.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|