openreward 0.1.95.dev0__tar.gz → 0.1.96.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 (77) hide show
  1. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/PKG-INFO +1 -1
  2. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/environments/client.py +2 -2
  3. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/toolsets/__init__.py +6 -6
  4. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/toolsets/hermes.py +113 -14
  5. openreward-0.1.96.dev1/openreward/toolsets/openclaw.py +369 -0
  6. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward.egg-info/PKG-INFO +1 -1
  7. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/pyproject.toml +1 -1
  8. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/tests/test_session_toolset.py +32 -10
  9. openreward-0.1.95.dev0/openreward/toolsets/openclaw.py +0 -222
  10. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/README.md +0 -0
  11. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/__init__.py +0 -0
  12. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/_version.py +0 -0
  13. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/__init__.py +0 -0
  14. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/_session/__init__.py +0 -0
  15. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/_session/http.py +0 -0
  16. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/_session/ping.py +0 -0
  17. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/_session/session.py +0 -0
  18. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/environments/__init__.py +0 -0
  19. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/environments/types.py +0 -0
  20. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/__init__.py +0 -0
  21. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/background.py +0 -0
  22. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/rollout.py +0 -0
  23. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/serializers/__init__.py +0 -0
  24. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/serializers/ant.py +0 -0
  25. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/serializers/base.py +0 -0
  26. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/serializers/gdm.py +0 -0
  27. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/serializers/models.py +0 -0
  28. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/serializers/oai_completions.py +0 -0
  29. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/serializers/oai_responses.py +0 -0
  30. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/rollouts/serializers/utils.py +0 -0
  31. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/sandboxes/__init__.py +0 -0
  32. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/sandboxes/client.py +0 -0
  33. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/sandboxes/secrets.py +0 -0
  34. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/api/sandboxes/types.py +0 -0
  35. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/cli.py +0 -0
  36. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/client.py +0 -0
  37. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/environments/__init__.py +0 -0
  38. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/environments/environment.py +0 -0
  39. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/environments/reconnect.py +0 -0
  40. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/environments/server.py +0 -0
  41. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/environments/session.py +0 -0
  42. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/environments/toolset.py +0 -0
  43. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/environments/types.py +0 -0
  44. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/environments/utils.py +0 -0
  45. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/http_client.py +0 -0
  46. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/log_utils.py +0 -0
  47. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/models.py +0 -0
  48. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/__init__.py +0 -0
  49. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/basic/Dockerfile +0 -0
  50. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/basic/__init__.py +0 -0
  51. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/basic/requirements.txt +0 -0
  52. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/basic/requirements.txt.tmpl +0 -0
  53. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/basic/server.py.tmpl +0 -0
  54. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/sandbox/Dockerfile +0 -0
  55. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/sandbox/__init__.py +0 -0
  56. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/sandbox/requirements.txt +0 -0
  57. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/sandbox/sandbox_env.py +0 -0
  58. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/templates/sandbox/server.py +0 -0
  59. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/toolsets/claude_code.py +0 -0
  60. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/toolsets/codex.py +0 -0
  61. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/toolsets/excel.py +0 -0
  62. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/toolsets/pdf.py +0 -0
  63. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/toolsets/powerpoint.py +0 -0
  64. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward/toolsets/word.py +0 -0
  65. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward.egg-info/SOURCES.txt +0 -0
  66. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward.egg-info/dependency_links.txt +0 -0
  67. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward.egg-info/entry_points.txt +0 -0
  68. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward.egg-info/requires.txt +0 -0
  69. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/openreward.egg-info/top_level.txt +0 -0
  70. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/setup.cfg +0 -0
  71. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/tests/test_environment.py +0 -0
  72. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/tests/test_errors.py +0 -0
  73. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/tests/test_rollout_info.py +0 -0
  74. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/tests/test_sanitise.py +0 -0
  75. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/tests/test_session.py +0 -0
  76. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/tests/test_tool_conversion.py +0 -0
  77. {openreward-0.1.95.dev0 → openreward-0.1.96.dev1}/tests/test_toolsets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreward
3
- Version: 0.1.95.dev0
3
+ Version: 0.1.96.dev1
4
4
  Summary: Python SDK for the OpenReward platform.
5
5
  Author-email: GR Inc <hello@gr.inc>
6
6
  Requires-Python: >=3.11
@@ -12,8 +12,8 @@ from openreward.api._session.http import (
12
12
  )
13
13
  from openreward.api._session.session import BaseAsyncSession, SessionTerminatedError
14
14
 
15
- BuiltinToolset = Literal["claude-code", "codex", "openclaw", "hermes"]
16
- _VALID_BUILTIN_TOOLSETS = {"claude-code", "codex", "openclaw", "hermes"}
15
+ BuiltinToolset = Literal["claude-code", "codex", "openclaw-sandboxed", "hermes-sandboxed"]
16
+ _VALID_BUILTIN_TOOLSETS = {"claude-code", "codex", "openclaw-sandboxed", "hermes-sandboxed"}
17
17
  from .types import (
18
18
  ImageBlock,
19
19
  JSONObject,
@@ -5,8 +5,8 @@ from openreward.environments.toolset import Toolset
5
5
  from .claude_code import ClaudeCodeToolset
6
6
  from .codex import CodexToolset
7
7
  from .excel import ExcelToolset
8
- from .hermes import HermesToolset
9
- from .openclaw import OpenClawToolset
8
+ from .hermes import HermesSandboxedToolset
9
+ from .openclaw import OpenClawSandboxedToolset
10
10
  from .pdf import PDFToolset
11
11
  from .powerpoint import PowerPointToolset
12
12
  from .word import WordToolset
@@ -18,8 +18,8 @@ from .word import WordToolset
18
18
  BUILTIN_TOOLSETS: dict[str, type[Toolset]] = {
19
19
  ClaudeCodeToolset.name(): ClaudeCodeToolset,
20
20
  CodexToolset.name(): CodexToolset,
21
- OpenClawToolset.name(): OpenClawToolset,
22
- HermesToolset.name(): HermesToolset,
21
+ OpenClawSandboxedToolset.name(): OpenClawSandboxedToolset,
22
+ HermesSandboxedToolset.name(): HermesSandboxedToolset,
23
23
  }
24
24
 
25
25
  __all__ = [
@@ -27,8 +27,8 @@ __all__ = [
27
27
  "ClaudeCodeToolset",
28
28
  "CodexToolset",
29
29
  "ExcelToolset",
30
- "HermesToolset",
31
- "OpenClawToolset",
30
+ "HermesSandboxedToolset",
31
+ "OpenClawSandboxedToolset",
32
32
  "PowerPointToolset",
33
33
  "WordToolset",
34
34
  "PDFToolset",
@@ -1,8 +1,14 @@
1
- """Hermes Agent session toolset.
1
+ """Hermes Agent sandboxed session toolset.
2
2
 
3
- Provides the four built-in tools that Hermes Agent exposes for coding tasks
4
- (``terminal``, ``read_file``, ``write_file``, ``patch``), each backed by
5
- ``self.sandbox`` from the bound environment.
3
+ Provides the five built-in tools that Hermes Agent exposes for coding tasks
4
+ (``terminal``, ``read_file``, ``write_file``, ``search_files``, ``patch``),
5
+ each backed by ``self.sandbox`` from the bound environment. All file and
6
+ shell operations are routed through the sandbox so the agent runs in an
7
+ isolated environment.
8
+
9
+ For the non-sandboxed variant (``hermes``), Hermes uses its own native tools
10
+ and only environment-specific tools (e.g. ``submit_answer``) are exposed via
11
+ MCP — no SDK toolset is needed.
6
12
 
7
13
  Tool names, parameter schemas, and descriptions match Hermes Agent's upstream
8
14
  registry definitions (``nousresearch/hermes-agent``).
@@ -11,7 +17,7 @@ from __future__ import annotations
11
17
 
12
18
  import base64
13
19
  import os
14
- from typing import Any, Optional
20
+ from typing import Any, List, Optional
15
21
 
16
22
  from pydantic import BaseModel
17
23
 
@@ -57,6 +63,17 @@ class WriteFileParams(BaseModel, extra="forbid"):
57
63
  content: str
58
64
 
59
65
 
66
+ class SearchFilesParams(BaseModel, extra="forbid"):
67
+ pattern: str
68
+ target: str = "content"
69
+ path: str = "."
70
+ file_glob: Optional[str] = None
71
+ limit: int = 50
72
+ offset: int = 0
73
+ output_mode: str = "content"
74
+ context: int = 0
75
+
76
+
60
77
  class PatchParams(BaseModel, extra="forbid"):
61
78
  mode: str = "replace"
62
79
  path: Optional[str] = None
@@ -66,13 +83,14 @@ class PatchParams(BaseModel, extra="forbid"):
66
83
  patch: Optional[str] = None
67
84
 
68
85
 
69
- # ── Tool descriptions (matching Hermes registry style) ──
86
+ # ── Tool descriptions (matching Hermes upstream registry) ──
70
87
 
71
88
  TERMINAL_DESCRIPTION = """\
72
89
  Execute shell commands on a Linux environment. Filesystem usually persists between calls.
73
90
 
74
91
  Do NOT use cat/head/tail to read files — use read_file instead.
75
92
  Do NOT use grep/rg/find to search — use search_files instead.
93
+ Do NOT use ls to list directories — use search_files(target='files') instead.
76
94
  Do NOT use sed/awk to edit files — use patch instead.
77
95
  Do NOT use echo/cat heredoc to create files — use write_file instead.
78
96
  Reserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.
@@ -89,6 +107,17 @@ Write content to a file, completely replacing existing content. Use this instead
89
107
  in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for \
90
108
  targeted edits."""
91
109
 
110
+ SEARCH_FILES_DESCRIPTION = """\
111
+ Search file contents or find files by name using regex/glob patterns. Use this instead of \
112
+ grep/rg/find/ls in terminal.
113
+
114
+ target='content' (default): search inside file contents with regex. Returns matching lines with \
115
+ line numbers and optional context.
116
+ target='files': search for files by name/glob pattern. Returns matching file paths.
117
+
118
+ Use file_glob to filter which files to search (e.g., '*.py'). Use output_mode to control \
119
+ output format: 'content' (matching lines), 'files_only' (file paths), 'count' (match counts)."""
120
+
92
121
  PATCH_DESCRIPTION = """\
93
122
  Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal.
94
123
 
@@ -101,12 +130,12 @@ Include enough surrounding context to ensure uniqueness."""
101
130
 
102
131
  # ── Toolset ──
103
132
 
104
- class HermesToolset(Toolset):
105
- """Session toolset exposing the Hermes Agent four-tool coding surface.
133
+ class HermesSandboxedToolset(Toolset):
134
+ """Sandboxed session toolset exposing the Hermes Agent five-tool coding surface.
106
135
 
107
136
  The toolset is bound to a session by passing it to ``env.session(...)``::
108
137
 
109
- with env.session(task=task, toolset="hermes") as session:
138
+ with env.session(task=task, toolset="hermes-sandboxed") as session:
110
139
  session.call_tool("terminal", {"command": "ls"})
111
140
 
112
141
  Requires the bound environment to define ``self.sandbox``.
@@ -114,7 +143,7 @@ class HermesToolset(Toolset):
114
143
 
115
144
  @classmethod
116
145
  def name(cls) -> str:
117
- return "hermes"
146
+ return "hermes-sandboxed"
118
147
 
119
148
  @tool
120
149
  async def terminal(self, params: TerminalParams) -> ToolOutput:
@@ -188,6 +217,75 @@ class HermesToolset(Toolset):
188
217
  finished=False,
189
218
  )
190
219
 
220
+ @tool
221
+ async def search_files(self, params: SearchFilesParams) -> ToolOutput:
222
+ try:
223
+ if params.target == "files":
224
+ # File name search using find
225
+ cmd = f"find {params.path} -type f -name '{params.pattern}'"
226
+ output, code = await self.sandbox.run(cmd)
227
+ if code != 0:
228
+ return ToolOutput(
229
+ metadata={"error": output, "exit_code": code},
230
+ blocks=[TextBlock(text=f"search_files failed (exit {code}):\n{output}")],
231
+ finished=False,
232
+ )
233
+ # Apply offset/limit to results
234
+ lines = [l for l in output.splitlines() if l.strip()]
235
+ lines = lines[params.offset:params.offset + params.limit]
236
+ result = "\n".join(lines)
237
+ return ToolOutput(
238
+ metadata={"output": result, "exit_code": 0},
239
+ blocks=[TextBlock(text=result if result else "No files found.")],
240
+ reward=0.0,
241
+ finished=False,
242
+ )
243
+ else:
244
+ # Content search using grep
245
+ glob_flag = ""
246
+ if params.file_glob:
247
+ glob_flag = f" --include='{params.file_glob}'"
248
+
249
+ context_flag = ""
250
+ if params.context > 0:
251
+ context_flag = f" -C {params.context}"
252
+
253
+ if params.output_mode == "files_only":
254
+ mode_flag = " -l"
255
+ elif params.output_mode == "count":
256
+ mode_flag = " -c"
257
+ else:
258
+ mode_flag = " -n"
259
+
260
+ cmd = f"grep -r{mode_flag}{context_flag}{glob_flag} '{params.pattern}' {params.path}"
261
+ output, code = await self.sandbox.run(cmd)
262
+
263
+ # grep returns 1 for no matches — not an error
264
+ if code > 1:
265
+ return ToolOutput(
266
+ metadata={"error": output, "exit_code": code},
267
+ blocks=[TextBlock(text=f"search_files failed (exit {code}):\n{output}")],
268
+ finished=False,
269
+ )
270
+
271
+ # Apply offset/limit to results
272
+ lines = output.splitlines()
273
+ lines = lines[params.offset:params.offset + params.limit]
274
+ result = "\n".join(lines)
275
+
276
+ return ToolOutput(
277
+ metadata={"output": result, "exit_code": 0},
278
+ blocks=[TextBlock(text=result if result else "No matches found.")],
279
+ reward=0.0,
280
+ finished=False,
281
+ )
282
+ except Exception as e:
283
+ return ToolOutput(
284
+ metadata={"error": str(e)},
285
+ blocks=[TextBlock(text=f"Error searching files: {str(e)}")],
286
+ finished=False,
287
+ )
288
+
191
289
  @tool
192
290
  async def patch(self, params: PatchParams) -> ToolOutput:
193
291
  if params.mode == "replace":
@@ -289,7 +387,8 @@ class HermesToolset(Toolset):
289
387
 
290
388
  # Assign descriptions onto each tool method's __doc__ so the framework's
291
389
  # introspection picks them up.
292
- HermesToolset.terminal.__doc__ = TERMINAL_DESCRIPTION
293
- HermesToolset.read_file.__doc__ = READ_FILE_DESCRIPTION
294
- HermesToolset.write_file.__doc__ = WRITE_FILE_DESCRIPTION
295
- HermesToolset.patch.__doc__ = PATCH_DESCRIPTION
390
+ HermesSandboxedToolset.terminal.__doc__ = TERMINAL_DESCRIPTION
391
+ HermesSandboxedToolset.read_file.__doc__ = READ_FILE_DESCRIPTION
392
+ HermesSandboxedToolset.write_file.__doc__ = WRITE_FILE_DESCRIPTION
393
+ HermesSandboxedToolset.search_files.__doc__ = SEARCH_FILES_DESCRIPTION
394
+ HermesSandboxedToolset.patch.__doc__ = PATCH_DESCRIPTION
@@ -0,0 +1,369 @@
1
+ """OpenClaw sandboxed session toolset.
2
+
3
+ Provides the built-in tools that OpenClaw exposes for coding tasks
4
+ (``exec``, ``process``, ``read``, ``write``, ``edit``, ``apply_patch``),
5
+ each backed by
6
+ ``self.sandbox`` from the bound environment. All file and shell operations
7
+ are routed through the sandbox so the agent runs in an isolated environment.
8
+
9
+ For the non-sandboxed variant (``openclaw``), OpenClaw uses its own native
10
+ tools and only environment-specific tools (e.g. ``submit_answer``) are
11
+ exposed via MCP — no SDK toolset is needed.
12
+
13
+ Tool names, parameter schemas, and descriptions match OpenClaw's upstream
14
+ definitions.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import os
20
+ from typing import Any, List, Optional
21
+
22
+ from pydantic import BaseModel, Field
23
+
24
+ from openreward.environments.environment import tool
25
+ from openreward.environments.toolset import Toolset
26
+ from openreward.environments.types import TextBlock, ToolOutput
27
+
28
+
29
+ # ── Sandbox text helpers (inlined; same as claude_code.py) ──
30
+
31
+ async def _download_text(sandbox: Any, path: str) -> str:
32
+ data = await sandbox.download(path)
33
+ return data.decode("utf-8")
34
+
35
+
36
+ async def _upload_text(
37
+ sandbox: Any,
38
+ path: str,
39
+ content: str,
40
+ ensure_trailing_newline: bool = True,
41
+ ) -> None:
42
+ if ensure_trailing_newline and not content.endswith("\n"):
43
+ content = content + "\n"
44
+ encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
45
+ await sandbox.check_run(f"echo '{encoded}' | base64 -d > {path}")
46
+
47
+
48
+ # ── Pydantic parameter models ──
49
+
50
+ class ExecParams(BaseModel, extra="forbid"):
51
+ command: str
52
+ timeout: Optional[float] = 1800.0
53
+
54
+
55
+ class ReadParams(BaseModel, extra="forbid"):
56
+ path: str
57
+ offset: Optional[int] = None
58
+ limit: Optional[int] = None
59
+
60
+
61
+ class WriteParams(BaseModel, extra="forbid"):
62
+ path: str
63
+ content: str
64
+
65
+
66
+ class EditItem(BaseModel, extra="forbid"):
67
+ oldText: str
68
+ newText: str
69
+
70
+
71
+ class EditParams(BaseModel, extra="forbid"):
72
+ path: str
73
+ edits: List[EditItem]
74
+
75
+
76
+ class ApplyPatchParams(BaseModel, extra="forbid"):
77
+ input: str
78
+
79
+
80
+ class ProcessParams(BaseModel, extra="forbid"):
81
+ action: str
82
+ sessionId: Optional[str] = None
83
+ data: Optional[str] = None
84
+ eof: Optional[bool] = None
85
+ offset: Optional[int] = None
86
+ limit: Optional[int] = None
87
+
88
+
89
+ # ── Tool descriptions (matching OpenClaw upstream) ──
90
+
91
+ EXEC_DESCRIPTION = """\
92
+ Execute a shell command and return its output and exit code. \
93
+ Set timeout in seconds (default 1800). \
94
+ Use for builds, installs, git, processes, scripts, network, package managers, \
95
+ and anything that needs a shell."""
96
+
97
+ READ_DESCRIPTION = """\
98
+ Read the contents of a file at the given path. \
99
+ Supports optional offset (1-indexed line number to start from) and limit \
100
+ (maximum number of lines to read) for pagination of large files."""
101
+
102
+ WRITE_DESCRIPTION = """\
103
+ Write content to a file, creating it if it doesn't exist or overwriting if it does. \
104
+ Creates parent directories automatically. \
105
+ Use the edit tool for targeted modifications to existing files."""
106
+
107
+ EDIT_DESCRIPTION = """\
108
+ Apply one or more targeted text replacements to a file. \
109
+ Each edit specifies an oldText to find and a newText to replace it with. \
110
+ oldText must be unique in the file for each edit."""
111
+
112
+ PROCESS_DESCRIPTION = """\
113
+ Manage background processes. Use exec with background=true to start a process, \
114
+ then use this tool to interact with it.
115
+
116
+ Actions:
117
+ - list: show running and finished background sessions
118
+ - poll: check for new output from a session (requires sessionId)
119
+ - log: read session output with optional offset/limit pagination (requires sessionId)
120
+ - write: send data to a session's stdin (requires sessionId and data)
121
+ - kill: terminate a background session (requires sessionId)
122
+ - remove: kill if running, clear if finished (requires sessionId)"""
123
+
124
+ APPLY_PATCH_DESCRIPTION = """\
125
+ Apply file modifications using a structured patch format, designed for multiple file \
126
+ or multi-hunk edits where individual edit calls would be fragile.
127
+
128
+ The input must include '*** Begin Patch' and '*** End Patch' markers. Supported \
129
+ operations: '*** Add File:', '*** Update File:' (with optional '*** Move to:'), \
130
+ '*** Delete File:', and '*** End of File' for EOF-only insertions."""
131
+
132
+
133
+ # ── Toolset ──
134
+
135
+ class OpenClawSandboxedToolset(Toolset):
136
+ """Sandboxed session toolset exposing the OpenClaw six-tool coding surface.
137
+
138
+ The toolset is bound to a session by passing it to ``env.session(...)``::
139
+
140
+ with env.session(task=task, toolset="openclaw-sandboxed") as session:
141
+ session.call_tool("exec", {"command": "ls"})
142
+
143
+ Requires the bound environment to define ``self.sandbox``.
144
+ """
145
+
146
+ @classmethod
147
+ def name(cls) -> str:
148
+ return "openclaw-sandboxed"
149
+
150
+ @tool
151
+ async def exec(self, params: ExecParams) -> ToolOutput:
152
+ try:
153
+ output, code = await self.sandbox.run(params.command.strip())
154
+ return ToolOutput(
155
+ blocks=[TextBlock(text=f"{output}\n\n(exit {code})")],
156
+ metadata={"output": output, "exit_code": code},
157
+ reward=0.0,
158
+ finished=False,
159
+ )
160
+ except Exception as e:
161
+ return ToolOutput(
162
+ metadata={"error": str(e)},
163
+ blocks=[TextBlock(text=f"Error executing command: {str(e)}")],
164
+ finished=False,
165
+ )
166
+
167
+ @tool
168
+ async def process(self, params: ProcessParams) -> ToolOutput:
169
+ try:
170
+ action = params.action
171
+ sid = params.sessionId or ""
172
+
173
+ if action == "list":
174
+ output, code = await self.sandbox.run("ps aux --no-headers 2>/dev/null || ps aux")
175
+ return ToolOutput(
176
+ blocks=[TextBlock(text=output if output.strip() else "No background processes.")],
177
+ metadata={"output": output, "exit_code": code},
178
+ reward=0.0,
179
+ finished=False,
180
+ )
181
+
182
+ if not sid:
183
+ return ToolOutput(
184
+ metadata={"error": "sessionId is required for this action"},
185
+ blocks=[TextBlock(text=f"Error: sessionId is required for action '{action}'.")],
186
+ finished=False,
187
+ )
188
+
189
+ if action in ("poll", "log"):
190
+ # Read output from a background process log file
191
+ tail_n = params.limit or 200
192
+ cmd = f"cat /tmp/_oc_proc_{sid}.log 2>/dev/null || echo 'No output available for session {sid}'"
193
+ if params.offset is not None:
194
+ cmd = f"tail -n +{params.offset} /tmp/_oc_proc_{sid}.log 2>/dev/null | head -n {tail_n}"
195
+ elif params.limit:
196
+ cmd = f"tail -n {tail_n} /tmp/_oc_proc_{sid}.log 2>/dev/null"
197
+ output, code = await self.sandbox.run(cmd)
198
+ return ToolOutput(
199
+ blocks=[TextBlock(text=output)],
200
+ metadata={"output": output, "exit_code": code},
201
+ reward=0.0,
202
+ finished=False,
203
+ )
204
+
205
+ if action == "write":
206
+ data = params.data or ""
207
+ # Write data to the process's stdin via a named pipe or file
208
+ output, code = await self.sandbox.run(
209
+ f"echo '{data}' >> /tmp/_oc_proc_{sid}.stdin 2>/dev/null"
210
+ )
211
+ return ToolOutput(
212
+ blocks=[TextBlock(text=f"Sent data to session {sid}")],
213
+ metadata={"output": output, "exit_code": code},
214
+ reward=0.0,
215
+ finished=False,
216
+ )
217
+
218
+ if action in ("kill", "remove"):
219
+ output, code = await self.sandbox.run(
220
+ f"kill $(cat /tmp/_oc_proc_{sid}.pid 2>/dev/null) 2>/dev/null; "
221
+ f"rm -f /tmp/_oc_proc_{sid}.pid /tmp/_oc_proc_{sid}.log /tmp/_oc_proc_{sid}.stdin"
222
+ )
223
+ return ToolOutput(
224
+ blocks=[TextBlock(text=f"Session {sid} terminated and cleaned up.")],
225
+ metadata={"output": output, "exit_code": code},
226
+ reward=0.0,
227
+ finished=False,
228
+ )
229
+
230
+ return ToolOutput(
231
+ metadata={"error": f"Unknown action: {action}"},
232
+ blocks=[TextBlock(text=f"Error: unknown process action '{action}'. "
233
+ "Use list, poll, log, write, kill, or remove.")],
234
+ finished=False,
235
+ )
236
+ except Exception as e:
237
+ return ToolOutput(
238
+ metadata={"error": str(e)},
239
+ blocks=[TextBlock(text=f"Error managing process: {str(e)}")],
240
+ finished=False,
241
+ )
242
+
243
+ @tool
244
+ async def read(self, params: ReadParams) -> ToolOutput:
245
+ try:
246
+ content = await _download_text(self.sandbox, params.path)
247
+ lines = content.splitlines()
248
+
249
+ if params.offset is not None or params.limit is not None:
250
+ start = (params.offset or 1) - 1 # Convert 1-indexed to 0-indexed
251
+ if params.limit is not None:
252
+ lines = lines[start:start + params.limit]
253
+ else:
254
+ lines = lines[start:]
255
+
256
+ output = "\n".join(lines)
257
+ return ToolOutput(
258
+ metadata={"output": output, "exit_code": 0},
259
+ blocks=[TextBlock(text=output)],
260
+ reward=0.0,
261
+ finished=False,
262
+ )
263
+ except Exception as e:
264
+ return ToolOutput(
265
+ metadata={"error": str(e)},
266
+ blocks=[TextBlock(text=f"Error reading file: {str(e)}")],
267
+ finished=False,
268
+ )
269
+
270
+ @tool
271
+ async def write(self, params: WriteParams) -> ToolOutput:
272
+ try:
273
+ dir_name = os.path.dirname(params.path)
274
+ if dir_name:
275
+ await self.sandbox.run(f"mkdir -p {dir_name}")
276
+ await _upload_text(
277
+ self.sandbox,
278
+ params.path,
279
+ params.content,
280
+ ensure_trailing_newline=True,
281
+ )
282
+ return ToolOutput(
283
+ metadata={"output": "", "exit_code": 0},
284
+ blocks=[TextBlock(text=f"Successfully wrote to {params.path}")],
285
+ reward=0.0,
286
+ finished=False,
287
+ )
288
+ except Exception as e:
289
+ return ToolOutput(
290
+ metadata={"error": str(e)},
291
+ blocks=[TextBlock(text=f"Error writing file: {str(e)}")],
292
+ finished=False,
293
+ )
294
+
295
+ @tool
296
+ async def edit(self, params: EditParams) -> ToolOutput:
297
+ try:
298
+ content = await _download_text(self.sandbox, params.path)
299
+
300
+ for item in params.edits:
301
+ count = content.count(item.oldText)
302
+ if count == 0:
303
+ return ToolOutput(
304
+ metadata={"error": f"oldText not found in {params.path}"},
305
+ blocks=[TextBlock(text=f"Error: oldText not found in {params.path}: {item.oldText[:100]}")],
306
+ finished=False,
307
+ )
308
+ if count > 1:
309
+ return ToolOutput(
310
+ metadata={"error": f"oldText appears {count} times; must be unique"},
311
+ blocks=[TextBlock(text=f"Error: oldText appears {count} times in {params.path}. Must be unique.")],
312
+ finished=False,
313
+ )
314
+ content = content.replace(item.oldText, item.newText, 1)
315
+
316
+ await _upload_text(self.sandbox, params.path, content, ensure_trailing_newline=True)
317
+
318
+ return ToolOutput(
319
+ metadata={"output": "", "exit_code": 0},
320
+ blocks=[TextBlock(text=f"Successfully edited {params.path}")],
321
+ reward=0.0,
322
+ finished=False,
323
+ )
324
+ except Exception as e:
325
+ return ToolOutput(
326
+ metadata={"error": str(e)},
327
+ blocks=[TextBlock(text=f"Error editing file: {str(e)}")],
328
+ finished=False,
329
+ )
330
+
331
+ @tool
332
+ async def apply_patch(self, params: ApplyPatchParams) -> ToolOutput:
333
+ try:
334
+ # Upload patch content to a temp file and apply via patch command
335
+ patch_tmp = "/tmp/_openclaw_patch.diff"
336
+ await _upload_text(self.sandbox, patch_tmp, params.input, ensure_trailing_newline=True)
337
+
338
+ output, code = await self.sandbox.run(f"patch -p1 < {patch_tmp}")
339
+ if code != 0:
340
+ await self.sandbox.run(f"rm -f {patch_tmp}")
341
+ return ToolOutput(
342
+ metadata={"error": output, "exit_code": code},
343
+ blocks=[TextBlock(text=f"apply_patch failed (exit {code}):\n{output}")],
344
+ finished=False,
345
+ )
346
+
347
+ await self.sandbox.run(f"rm -f {patch_tmp}")
348
+ return ToolOutput(
349
+ metadata={"output": output, "exit_code": 0},
350
+ blocks=[TextBlock(text=f"Successfully applied patch:\n{output}")],
351
+ reward=0.0,
352
+ finished=False,
353
+ )
354
+ except Exception as e:
355
+ return ToolOutput(
356
+ metadata={"error": str(e)},
357
+ blocks=[TextBlock(text=f"Error applying patch: {str(e)}")],
358
+ finished=False,
359
+ )
360
+
361
+
362
+ # Assign descriptions onto each tool method's __doc__ so the framework's
363
+ # introspection picks them up.
364
+ OpenClawSandboxedToolset.exec.__doc__ = EXEC_DESCRIPTION
365
+ OpenClawSandboxedToolset.process.__doc__ = PROCESS_DESCRIPTION
366
+ OpenClawSandboxedToolset.read.__doc__ = READ_DESCRIPTION
367
+ OpenClawSandboxedToolset.write.__doc__ = WRITE_DESCRIPTION
368
+ OpenClawSandboxedToolset.edit.__doc__ = EDIT_DESCRIPTION
369
+ OpenClawSandboxedToolset.apply_patch.__doc__ = APPLY_PATCH_DESCRIPTION
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreward
3
- Version: 0.1.95.dev0
3
+ Version: 0.1.96.dev1
4
4
  Summary: Python SDK for the OpenReward platform.
5
5
  Author-email: GR Inc <hello@gr.inc>
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openreward"
3
- version = "0.1.95.dev0"
3
+ version = "0.1.96.dev1"
4
4
  description = "Python SDK for the OpenReward platform."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -287,14 +287,14 @@ async def test_session_toolset_warns_on_shadow(monkeypatch):
287
287
 
288
288
  # ── OpenClaw toolset ──
289
289
 
290
- OPENCLAW_TOOLS = {"exec", "read", "write", "edit"}
290
+ OPENCLAW_TOOLS = {"exec", "process", "read", "write", "edit", "apply_patch"}
291
291
 
292
292
 
293
293
  @pytest.mark.asyncio
294
294
  async def test_openclaw_toolset_tools(client: AsyncOpenReward, server: str):
295
295
  env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
296
296
  tasks = await env.list_tasks(split="train")
297
- async with env.session(tasks[0], toolset="openclaw") as session:
297
+ async with env.session(tasks[0], toolset="openclaw-sandboxed") as session:
298
298
  tools = await session.list_tools()
299
299
  toolset_names = {t.name for t in tools}
300
300
  for name in OPENCLAW_TOOLS:
@@ -310,7 +310,7 @@ async def test_openclaw_toolset_tools(client: AsyncOpenReward, server: str):
310
310
  async def test_openclaw_exec_routes_to_sandbox(client: AsyncOpenReward, server: str):
311
311
  env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
312
312
  tasks = await env.list_tasks(split="train")
313
- async with env.session(tasks[0], toolset="openclaw") as session:
313
+ async with env.session(tasks[0], toolset="openclaw-sandboxed") as session:
314
314
  result = await session.call_tool("exec", {"command": "echo hi"})
315
315
  assert "ran: echo hi" in result.blocks[0].text
316
316
 
@@ -319,7 +319,7 @@ async def test_openclaw_exec_routes_to_sandbox(client: AsyncOpenReward, server:
319
319
  async def test_openclaw_write_then_read_roundtrip(client: AsyncOpenReward, server: str):
320
320
  env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
321
321
  tasks = await env.list_tasks(split="train")
322
- async with env.session(tasks[0], toolset="openclaw") as session:
322
+ async with env.session(tasks[0], toolset="openclaw-sandboxed") as session:
323
323
  await session.call_tool("write", {"path": "/tmp/oc.txt", "content": "hello openclaw"})
324
324
  result = await session.call_tool("read", {"path": "/tmp/oc.txt"})
325
325
  assert "hello openclaw" in result.blocks[0].text
@@ -329,7 +329,7 @@ async def test_openclaw_write_then_read_roundtrip(client: AsyncOpenReward, serve
329
329
  async def test_openclaw_edit_with_edits_array(client: AsyncOpenReward, server: str):
330
330
  env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
331
331
  tasks = await env.list_tasks(split="train")
332
- async with env.session(tasks[0], toolset="openclaw") as session:
332
+ async with env.session(tasks[0], toolset="openclaw-sandboxed") as session:
333
333
  await session.call_tool("write", {"path": "/tmp/oc_edit.txt", "content": "foo bar baz"})
334
334
  result = await session.call_tool("edit", {
335
335
  "path": "/tmp/oc_edit.txt",
@@ -341,16 +341,28 @@ async def test_openclaw_edit_with_edits_array(client: AsyncOpenReward, server: s
341
341
  assert "bar" not in read_result.blocks[0].text
342
342
 
343
343
 
344
+ @pytest.mark.asyncio
345
+ async def test_openclaw_apply_patch(client: AsyncOpenReward, server: str):
346
+ env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
347
+ tasks = await env.list_tasks(split="train")
348
+ async with env.session(tasks[0], toolset="openclaw-sandboxed") as session:
349
+ result = await session.call_tool("apply_patch", {
350
+ "input": "*** Begin Patch\n*** Update File: /tmp/test.txt\n--- old\n+++ new\n*** End Patch",
351
+ })
352
+ # apply_patch runs patch under the hood; mock sandbox returns "ran: ..."
353
+ assert result.blocks[0].text is not None
354
+
355
+
344
356
  # ── Hermes toolset ──
345
357
 
346
- HERMES_TOOLS = {"terminal", "read_file", "write_file", "patch"}
358
+ HERMES_TOOLS = {"terminal", "read_file", "write_file", "search_files", "patch"}
347
359
 
348
360
 
349
361
  @pytest.mark.asyncio
350
362
  async def test_hermes_toolset_tools(client: AsyncOpenReward, server: str):
351
363
  env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
352
364
  tasks = await env.list_tasks(split="train")
353
- async with env.session(tasks[0], toolset="hermes") as session:
365
+ async with env.session(tasks[0], toolset="hermes-sandboxed") as session:
354
366
  tools = await session.list_tools()
355
367
  toolset_names = {t.name for t in tools}
356
368
  for name in HERMES_TOOLS:
@@ -364,7 +376,7 @@ async def test_hermes_toolset_tools(client: AsyncOpenReward, server: str):
364
376
  async def test_hermes_terminal_routes_to_sandbox(client: AsyncOpenReward, server: str):
365
377
  env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
366
378
  tasks = await env.list_tasks(split="train")
367
- async with env.session(tasks[0], toolset="hermes") as session:
379
+ async with env.session(tasks[0], toolset="hermes-sandboxed") as session:
368
380
  result = await session.call_tool("terminal", {"command": "echo hi"})
369
381
  assert "ran: echo hi" in result.blocks[0].text
370
382
 
@@ -373,7 +385,7 @@ async def test_hermes_terminal_routes_to_sandbox(client: AsyncOpenReward, server
373
385
  async def test_hermes_write_then_read_roundtrip(client: AsyncOpenReward, server: str):
374
386
  env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
375
387
  tasks = await env.list_tasks(split="train")
376
- async with env.session(tasks[0], toolset="hermes") as session:
388
+ async with env.session(tasks[0], toolset="hermes-sandboxed") as session:
377
389
  await session.call_tool("write_file", {"path": "/tmp/hm.txt", "content": "hello hermes"})
378
390
  result = await session.call_tool("read_file", {"path": "/tmp/hm.txt"})
379
391
  # Hermes read_file uses LINE_NUM|CONTENT format
@@ -381,11 +393,21 @@ async def test_hermes_write_then_read_roundtrip(client: AsyncOpenReward, server:
381
393
  assert "1|" in result.blocks[0].text
382
394
 
383
395
 
396
+ @pytest.mark.asyncio
397
+ async def test_hermes_search_files_content(client: AsyncOpenReward, server: str):
398
+ env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
399
+ tasks = await env.list_tasks(split="train")
400
+ async with env.session(tasks[0], toolset="hermes-sandboxed") as session:
401
+ result = await session.call_tool("search_files", {"pattern": "hello", "path": "/tmp"})
402
+ # search_files runs grep under the hood; mock sandbox returns "ran: ..."
403
+ assert result.blocks[0].text is not None
404
+
405
+
384
406
  @pytest.mark.asyncio
385
407
  async def test_hermes_patch_replace_mode(client: AsyncOpenReward, server: str):
386
408
  env = client.environments.get("envwithsandbox", variant="envwithsandbox", base_url=server)
387
409
  tasks = await env.list_tasks(split="train")
388
- async with env.session(tasks[0], toolset="hermes") as session:
410
+ async with env.session(tasks[0], toolset="hermes-sandboxed") as session:
389
411
  await session.call_tool("write_file", {"path": "/tmp/hm_patch.txt", "content": "foo bar baz"})
390
412
  result = await session.call_tool("patch", {
391
413
  "mode": "replace",
@@ -1,222 +0,0 @@
1
- """OpenClaw session toolset.
2
-
3
- Provides the four built-in tools that OpenClaw exposes for coding tasks
4
- (``exec``, ``read``, ``write``, ``edit``), each backed by ``self.sandbox``
5
- from the bound environment.
6
-
7
- Tool names, parameter schemas, and descriptions match OpenClaw's upstream
8
- TypeBox definitions (``@mariozechner/pi-coding-agent``).
9
- """
10
- from __future__ import annotations
11
-
12
- import base64
13
- import os
14
- from typing import Any, List, Optional
15
-
16
- from pydantic import BaseModel, Field
17
-
18
- from openreward.environments.environment import tool
19
- from openreward.environments.toolset import Toolset
20
- from openreward.environments.types import TextBlock, ToolOutput
21
-
22
-
23
- # ── Sandbox text helpers (inlined; same as claude_code.py) ──
24
-
25
- async def _download_text(sandbox: Any, path: str) -> str:
26
- data = await sandbox.download(path)
27
- return data.decode("utf-8")
28
-
29
-
30
- async def _upload_text(
31
- sandbox: Any,
32
- path: str,
33
- content: str,
34
- ensure_trailing_newline: bool = True,
35
- ) -> None:
36
- if ensure_trailing_newline and not content.endswith("\n"):
37
- content = content + "\n"
38
- encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
39
- await sandbox.check_run(f"echo '{encoded}' | base64 -d > {path}")
40
-
41
-
42
- # ── Pydantic parameter models ──
43
-
44
- class ExecParams(BaseModel, extra="forbid"):
45
- command: str
46
- timeout: Optional[float] = 1800.0
47
-
48
-
49
- class ReadParams(BaseModel, extra="forbid"):
50
- path: str
51
- offset: Optional[int] = None
52
- limit: Optional[int] = None
53
-
54
-
55
- class WriteParams(BaseModel, extra="forbid"):
56
- path: str
57
- content: str
58
-
59
-
60
- class EditItem(BaseModel, extra="forbid"):
61
- oldText: str
62
- newText: str
63
-
64
-
65
- class EditParams(BaseModel, extra="forbid"):
66
- path: str
67
- edits: List[EditItem]
68
-
69
-
70
- # ── Tool descriptions (matching OpenClaw TypeBox style) ──
71
-
72
- EXEC_DESCRIPTION = """\
73
- Execute a shell command and return its output and exit code. \
74
- Set timeout in seconds (default 1800). \
75
- Use for builds, installs, git, processes, scripts, network, package managers, \
76
- and anything that needs a shell."""
77
-
78
- READ_DESCRIPTION = """\
79
- Read the contents of a file at the given path. \
80
- Supports optional offset (1-indexed line number to start from) and limit \
81
- (maximum number of lines to read) for pagination of large files."""
82
-
83
- WRITE_DESCRIPTION = """\
84
- Write content to a file, creating it if it doesn't exist or overwriting if it does. \
85
- Creates parent directories automatically. \
86
- Use the edit tool for targeted modifications to existing files."""
87
-
88
- EDIT_DESCRIPTION = """\
89
- Apply one or more targeted text replacements to a file. \
90
- Each edit specifies an oldText to find and a newText to replace it with. \
91
- oldText must be unique in the file for each edit."""
92
-
93
-
94
- # ── Toolset ──
95
-
96
- class OpenClawToolset(Toolset):
97
- """Session toolset exposing the OpenClaw four-tool coding surface.
98
-
99
- The toolset is bound to a session by passing it to ``env.session(...)``::
100
-
101
- with env.session(task=task, toolset="openclaw") as session:
102
- session.call_tool("exec", {"command": "ls"})
103
-
104
- Requires the bound environment to define ``self.sandbox``.
105
- """
106
-
107
- @classmethod
108
- def name(cls) -> str:
109
- return "openclaw"
110
-
111
- @tool
112
- async def exec(self, params: ExecParams) -> ToolOutput:
113
- try:
114
- output, code = await self.sandbox.run(params.command.strip())
115
- return ToolOutput(
116
- blocks=[TextBlock(text=f"{output}\n\n(exit {code})")],
117
- metadata={"output": output, "exit_code": code},
118
- reward=0.0,
119
- finished=False,
120
- )
121
- except Exception as e:
122
- return ToolOutput(
123
- metadata={"error": str(e)},
124
- blocks=[TextBlock(text=f"Error executing command: {str(e)}")],
125
- finished=False,
126
- )
127
-
128
- @tool
129
- async def read(self, params: ReadParams) -> ToolOutput:
130
- try:
131
- content = await _download_text(self.sandbox, params.path)
132
- lines = content.splitlines()
133
-
134
- if params.offset is not None or params.limit is not None:
135
- start = (params.offset or 1) - 1 # Convert 1-indexed to 0-indexed
136
- if params.limit is not None:
137
- lines = lines[start:start + params.limit]
138
- else:
139
- lines = lines[start:]
140
-
141
- output = "\n".join(lines)
142
- return ToolOutput(
143
- metadata={"output": output, "exit_code": 0},
144
- blocks=[TextBlock(text=output)],
145
- reward=0.0,
146
- finished=False,
147
- )
148
- except Exception as e:
149
- return ToolOutput(
150
- metadata={"error": str(e)},
151
- blocks=[TextBlock(text=f"Error reading file: {str(e)}")],
152
- finished=False,
153
- )
154
-
155
- @tool
156
- async def write(self, params: WriteParams) -> ToolOutput:
157
- try:
158
- dir_name = os.path.dirname(params.path)
159
- if dir_name:
160
- await self.sandbox.run(f"mkdir -p {dir_name}")
161
- await _upload_text(
162
- self.sandbox,
163
- params.path,
164
- params.content,
165
- ensure_trailing_newline=True,
166
- )
167
- return ToolOutput(
168
- metadata={"output": "", "exit_code": 0},
169
- blocks=[TextBlock(text=f"Successfully wrote to {params.path}")],
170
- reward=0.0,
171
- finished=False,
172
- )
173
- except Exception as e:
174
- return ToolOutput(
175
- metadata={"error": str(e)},
176
- blocks=[TextBlock(text=f"Error writing file: {str(e)}")],
177
- finished=False,
178
- )
179
-
180
- @tool
181
- async def edit(self, params: EditParams) -> ToolOutput:
182
- try:
183
- content = await _download_text(self.sandbox, params.path)
184
-
185
- for item in params.edits:
186
- count = content.count(item.oldText)
187
- if count == 0:
188
- return ToolOutput(
189
- metadata={"error": f"oldText not found in {params.path}"},
190
- blocks=[TextBlock(text=f"Error: oldText not found in {params.path}: {item.oldText[:100]}")],
191
- finished=False,
192
- )
193
- if count > 1:
194
- return ToolOutput(
195
- metadata={"error": f"oldText appears {count} times; must be unique"},
196
- blocks=[TextBlock(text=f"Error: oldText appears {count} times in {params.path}. Must be unique.")],
197
- finished=False,
198
- )
199
- content = content.replace(item.oldText, item.newText, 1)
200
-
201
- await _upload_text(self.sandbox, params.path, content, ensure_trailing_newline=True)
202
-
203
- return ToolOutput(
204
- metadata={"output": "", "exit_code": 0},
205
- blocks=[TextBlock(text=f"Successfully edited {params.path}")],
206
- reward=0.0,
207
- finished=False,
208
- )
209
- except Exception as e:
210
- return ToolOutput(
211
- metadata={"error": str(e)},
212
- blocks=[TextBlock(text=f"Error editing file: {str(e)}")],
213
- finished=False,
214
- )
215
-
216
-
217
- # Assign descriptions onto each tool method's __doc__ so the framework's
218
- # introspection picks them up.
219
- OpenClawToolset.exec.__doc__ = EXEC_DESCRIPTION
220
- OpenClawToolset.read.__doc__ = READ_DESCRIPTION
221
- OpenClawToolset.write.__doc__ = WRITE_DESCRIPTION
222
- OpenClawToolset.edit.__doc__ = EDIT_DESCRIPTION