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.
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/PKG-INFO +3 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/README.md +2 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/environments/client.py +1 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/errors.py +5 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/sandboxes/client.py +24 -53
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/sandboxes/types.py +5 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/_sandbox_tools.py +63 -4
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/environment.py +80 -13
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/server.py +2 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/types.py +1 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/sandbox_env.py +1 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/backsearch.py +2 -2
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/claude_code.py +7 -7
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/cli.py +9 -9
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/codex.py +1 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/gemini_cli.py +91 -75
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/hermes.py +80 -64
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/openclaw.py +78 -62
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/web.py +2 -2
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/PKG-INFO +3 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/SOURCES.txt +2 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/pyproject.toml +1 -1
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_cli_toolset_root_and_timeout.py +0 -3
- openreward-0.1.161.dev0/tests/test_episode_finished.py +212 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_gemini_cli_toolset.py +5 -3
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_sandbox_tool_shell_safety.py +0 -5
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_sandbox_tools.py +5 -3
- openreward-0.1.161.dev0/tests/test_sandboxes_client_paths.py +173 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_session_toolset.py +6 -3
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_terminal_tool.py +5 -3
- openreward-0.1.161.dev0/tests/test_tool_concurrency.py +231 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_toolsets.py +7 -4
- openreward-0.1.159.dev2/tests/test_sandboxes_client_paths.py +0 -158
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/LICENSE +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/_update_check.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/_version.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/_session/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/_session/http.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/_session/ping.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/_session/session.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/environments/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/environments/types.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/background.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/rollout.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/ant.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/base.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/gdm.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/models.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/oai_completions.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/oai_responses.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/rollouts/serializers/utils.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/sandboxes/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/api/sandboxes/secrets.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/chat_backends/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/chat_backends/base.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/chat_backends/openai.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/cli.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/client.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/_metrics.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/reconnect.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/session.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/toolset.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/utils.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/http_client.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/_render.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/group.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/groupwise.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/pairwise.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/judging/types.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/log_utils.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/models.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/search_backends/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/search_backends/backsearch.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/search_backends/base.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/search_backends/tavily.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/Dockerfile +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/requirements.txt +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/requirements.txt.tmpl +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/basic/server.py.tmpl +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/Dockerfile +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/requirements.txt +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/templates/sandbox/server.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/tools/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/tools/search.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/tools/web.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/__init__.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/_web_common.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/excel.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/pdf.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/powerpoint.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/toolsets/word.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/web_service.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/dependency_links.txt +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/entry_points.txt +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/requires.txt +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward.egg-info/top_level.txt +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/setup.cfg +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_chat_backends.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_cli_toolset_todo_write.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_env_server_retry.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_environment.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_errors.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_excel_toolset.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_judging.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_log_utils.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_messages.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_metrics.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_rich_messages.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_rollout_info.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_sandbox_secrets.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_sanitise.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_score_group.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_search_backends.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_session.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_tool_conversion.py +0 -0
- {openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/tests/test_toolset_shell_safety.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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:
|
{openreward-0.1.159.dev2 → openreward-0.1.161.dev0}/openreward/environments/_sandbox_tools.py
RENAMED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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"):
|
|
@@ -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
|
|