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.
Files changed (125) hide show
  1. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/PKG-INFO +1 -1
  2. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/_sandbox_tools.py +19 -4
  3. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/environment.py +103 -18
  4. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/cli.py +21 -2
  5. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/PKG-INFO +1 -1
  6. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/pyproject.toml +1 -1
  7. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_cli_toolset_root_and_timeout.py +50 -0
  8. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_tool_concurrency.py +126 -0
  9. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/LICENSE +0 -0
  10. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/README.md +0 -0
  11. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/__init__.py +0 -0
  12. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/_update_check.py +0 -0
  13. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/_version.py +0 -0
  14. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/__init__.py +0 -0
  15. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/_session/__init__.py +0 -0
  16. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/_session/http.py +0 -0
  17. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/_session/ping.py +0 -0
  18. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/_session/session.py +0 -0
  19. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/environments/__init__.py +0 -0
  20. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/environments/client.py +0 -0
  21. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/environments/types.py +0 -0
  22. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/errors.py +0 -0
  23. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/__init__.py +0 -0
  24. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/background.py +0 -0
  25. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/rollout.py +0 -0
  26. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/__init__.py +0 -0
  27. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/ant.py +0 -0
  28. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/base.py +0 -0
  29. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/gdm.py +0 -0
  30. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/models.py +0 -0
  31. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/oai_completions.py +0 -0
  32. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/oai_responses.py +0 -0
  33. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/rollouts/serializers/utils.py +0 -0
  34. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/sandboxes/__init__.py +0 -0
  35. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/sandboxes/client.py +0 -0
  36. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/sandboxes/secrets.py +0 -0
  37. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/api/sandboxes/types.py +0 -0
  38. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/chat_backends/__init__.py +0 -0
  39. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/chat_backends/base.py +0 -0
  40. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/chat_backends/openai.py +0 -0
  41. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/cli.py +0 -0
  42. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/client.py +0 -0
  43. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/__init__.py +0 -0
  44. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/_metrics.py +0 -0
  45. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/reconnect.py +0 -0
  46. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/server.py +0 -0
  47. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/session.py +0 -0
  48. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/toolset.py +0 -0
  49. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/types.py +0 -0
  50. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/environments/utils.py +0 -0
  51. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/http_client.py +0 -0
  52. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/__init__.py +0 -0
  53. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/_render.py +0 -0
  54. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/group.py +0 -0
  55. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/groupwise.py +0 -0
  56. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/pairwise.py +0 -0
  57. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/judging/types.py +0 -0
  58. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/log_utils.py +0 -0
  59. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/models.py +0 -0
  60. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/__init__.py +0 -0
  61. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/backsearch.py +0 -0
  62. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/base.py +0 -0
  63. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/search_backends/tavily.py +0 -0
  64. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/__init__.py +0 -0
  65. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/Dockerfile +0 -0
  66. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/__init__.py +0 -0
  67. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/requirements.txt +0 -0
  68. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/requirements.txt.tmpl +0 -0
  69. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/basic/server.py.tmpl +0 -0
  70. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/Dockerfile +0 -0
  71. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/__init__.py +0 -0
  72. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/requirements.txt +0 -0
  73. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/sandbox_env.py +0 -0
  74. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/templates/sandbox/server.py +0 -0
  75. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/tools/__init__.py +0 -0
  76. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/tools/search.py +0 -0
  77. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/tools/web.py +0 -0
  78. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/__init__.py +0 -0
  79. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/_web_common.py +0 -0
  80. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/backsearch.py +0 -0
  81. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/claude_code.py +0 -0
  82. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/codex.py +0 -0
  83. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/excel.py +0 -0
  84. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/gemini_cli.py +0 -0
  85. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/hermes.py +0 -0
  86. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/openclaw.py +0 -0
  87. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/pdf.py +0 -0
  88. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/powerpoint.py +0 -0
  89. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/web.py +0 -0
  90. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/toolsets/word.py +0 -0
  91. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward/web_service.py +0 -0
  92. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/SOURCES.txt +0 -0
  93. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/dependency_links.txt +0 -0
  94. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/entry_points.txt +0 -0
  95. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/requires.txt +0 -0
  96. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/openreward.egg-info/top_level.txt +0 -0
  97. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/setup.cfg +0 -0
  98. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_chat_backends.py +0 -0
  99. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_cli_toolset_todo_write.py +0 -0
  100. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_env_server_retry.py +0 -0
  101. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_environment.py +0 -0
  102. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_episode_finished.py +0 -0
  103. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_errors.py +0 -0
  104. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_excel_toolset.py +0 -0
  105. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_gemini_cli_toolset.py +0 -0
  106. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_judging.py +0 -0
  107. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_log_utils.py +0 -0
  108. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_messages.py +0 -0
  109. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_metrics.py +0 -0
  110. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_rich_messages.py +0 -0
  111. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_rollout_info.py +0 -0
  112. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sandbox_secrets.py +0 -0
  113. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sandbox_tool_shell_safety.py +0 -0
  114. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sandbox_tools.py +0 -0
  115. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sandboxes_client_paths.py +0 -0
  116. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_sanitise.py +0 -0
  117. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_score_group.py +0 -0
  118. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_search_backends.py +0 -0
  119. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_session.py +0 -0
  120. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_session_toolset.py +0 -0
  121. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_terminal_tool.py +0 -0
  122. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_tool_conversion.py +0 -0
  123. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_toolset_shell_safety.py +0 -0
  124. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/tests/test_toolsets.py +0 -0
  125. {openreward-0.1.161.dev0 → openreward-0.1.161.dev1}/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.161.dev0
3
+ Version: 0.1.161.dev1
4
4
  Summary: Python SDK for the OpenReward platform.
5
5
  Author-email: GR Inc <hello@gr.inc>
6
6
  License-Expression: MIT
@@ -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
- output, code = await sandbox.run(_rooted_command(command.strip(), root), **kwargs)
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={"output": output, "exit_code": code},
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 typing import Any, Awaitable, Callable, ClassVar, Optional, Sequence, TypeVar, Union, get_type_hints, overload
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
- fn: Optional[Callable[..., Any]] = None, *, shared: bool = True, concurrent: bool = False
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 getattr(fn, "_env_tool_terminal", False)
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
- return await self._run_tool_fn(name, fn, input, start)
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
- 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)
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
- start: float,
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, params.timeout, root=self.root
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreward
3
- Version: 0.1.161.dev0
3
+ Version: 0.1.161.dev1
4
4
  Summary: Python SDK for the OpenReward platform.
5
5
  Author-email: GR Inc <hello@gr.inc>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openreward"
3
- version = "0.1.161.dev0"
3
+ version = "0.1.161.dev1"
4
4
  description = "Python SDK for the OpenReward platform."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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