regcode 0.1.0__py3-none-any.whl
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.
- regcode/__init__.py +5 -0
- regcode/cli.py +180 -0
- regcode/config.py +153 -0
- regcode/conversation_manager.py +154 -0
- regcode/main.py +893 -0
- regcode/monty_sandbox.py +415 -0
- regcode/permissions.py +27 -0
- regcode/sandbox.py +382 -0
- regcode/tools/__init__.py +13 -0
- regcode/tools/base.py +125 -0
- regcode/tools/builtins.py +947 -0
- regcode/tools/registry.py +78 -0
- regcode/tools/review_notes.py +122 -0
- regcode/tui.py +331 -0
- regcode-0.1.0.dist-info/METADATA +163 -0
- regcode-0.1.0.dist-info/RECORD +18 -0
- regcode-0.1.0.dist-info/WHEEL +4 -0
- regcode-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
"""Built-in tools for the regcode agent.
|
|
2
|
+
|
|
3
|
+
Provides tools for code execution, file operations, and system info
|
|
4
|
+
using the Monty sandbox for secure, isolated Python execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from regcode.monty_sandbox import MontySandbox
|
|
16
|
+
from regcode.tools.base import BaseTool, ToolParam, ToolResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PythonCodeTool(BaseTool):
|
|
20
|
+
"""Execute Python code in a Monty sandbox."""
|
|
21
|
+
|
|
22
|
+
name = "python_code"
|
|
23
|
+
description = (
|
|
24
|
+
"Execute Python code in an isolated Monty sandbox "
|
|
25
|
+
"environment with Rust-based security and resource limits."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def params(self) -> list[ToolParam]:
|
|
30
|
+
return [
|
|
31
|
+
ToolParam("code", "string", "Python code to execute", required=True),
|
|
32
|
+
ToolParam(
|
|
33
|
+
"timeout",
|
|
34
|
+
"number",
|
|
35
|
+
"Execution timeout in seconds",
|
|
36
|
+
required=False,
|
|
37
|
+
default=10,
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
42
|
+
code = kwargs.get("code")
|
|
43
|
+
timeout = kwargs.get("timeout", 10)
|
|
44
|
+
if not code:
|
|
45
|
+
return ToolResult(
|
|
46
|
+
output="Missing required parameter: code",
|
|
47
|
+
error=True,
|
|
48
|
+
)
|
|
49
|
+
# Coerce timeout to float (LLM may send string like "15")
|
|
50
|
+
try:
|
|
51
|
+
timeout = float(timeout)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
timeout = 10.0
|
|
54
|
+
sandbox = MontySandbox()
|
|
55
|
+
try:
|
|
56
|
+
result = sandbox.run_python(code, timeout=timeout)
|
|
57
|
+
return ToolResult(
|
|
58
|
+
output=(
|
|
59
|
+
f"stdout: {result.stdout}\n"
|
|
60
|
+
f"stderr: {result.stderr}\n"
|
|
61
|
+
f"exit_code: {result.exit_code}\n"
|
|
62
|
+
f"time: {result.execution_time:.2f}s\n"
|
|
63
|
+
f"result: {result.result}"
|
|
64
|
+
),
|
|
65
|
+
error=not result.success,
|
|
66
|
+
stdout=result.stdout,
|
|
67
|
+
stderr=result.stderr,
|
|
68
|
+
exit_code=result.exit_code,
|
|
69
|
+
result=result.result,
|
|
70
|
+
)
|
|
71
|
+
finally:
|
|
72
|
+
sandbox.cleanup()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ShellCommandTool(BaseTool):
|
|
76
|
+
"""Execute shell commands with security restrictions.
|
|
77
|
+
|
|
78
|
+
Runs commands through a controlled allowlist to prevent arbitrary
|
|
79
|
+
command execution. Only safe, read-only commands are permitted.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
name = "shell_command"
|
|
83
|
+
description = (
|
|
84
|
+
"Execute a shell command with security restrictions. "
|
|
85
|
+
"Only a controlled set of read-only commands are allowed "
|
|
86
|
+
"(ls, cat, echo, head, tail, wc, grep, sort, uniq, date, pwd, whoami, "
|
|
87
|
+
"id, uname, df, free, which, type). Commands are subject to timeout limits."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Whitelist of safe, read-only shell commands
|
|
91
|
+
ALLOWED_COMMANDS = frozenset([
|
|
92
|
+
"ls", "cat", "echo", "head", "tail", "wc", "grep", "sort",
|
|
93
|
+
"uniq", "date", "pwd", "whoami", "id", "uname", "df",
|
|
94
|
+
"free", "which", "type", "stat", "file", "sha256sum",
|
|
95
|
+
"md5sum", "base64", "xxd", "od", "hexdump", "find",
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def params(self) -> list[ToolParam]:
|
|
100
|
+
return [
|
|
101
|
+
ToolParam(
|
|
102
|
+
"command",
|
|
103
|
+
"string",
|
|
104
|
+
"Shell command to execute (restricted to safe commands)",
|
|
105
|
+
required=True,
|
|
106
|
+
),
|
|
107
|
+
ToolParam(
|
|
108
|
+
"timeout",
|
|
109
|
+
"number",
|
|
110
|
+
"Execution timeout in seconds",
|
|
111
|
+
required=False,
|
|
112
|
+
default=10,
|
|
113
|
+
),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _validate_command(command: str) -> tuple[bool, str]:
|
|
118
|
+
"""Validate that the command is safe to execute.
|
|
119
|
+
|
|
120
|
+
Returns (is_valid, error_message).
|
|
121
|
+
"""
|
|
122
|
+
if not command or not command.strip():
|
|
123
|
+
return False, "Empty command"
|
|
124
|
+
|
|
125
|
+
# Strip quotes and leading/trailing whitespace
|
|
126
|
+
cmd = command.strip()
|
|
127
|
+
cmd = cmd.strip("'\"")
|
|
128
|
+
|
|
129
|
+
# Block dangerous characters that could break out of the command.
|
|
130
|
+
# We allow quotes for multi-word arguments but validate their content.
|
|
131
|
+
# CRITICAL: Do NOT allow & | ; $ ` ( ) ! < > because they enable
|
|
132
|
+
# command chaining (&&, ||, |, &), substitution ($(..), `..`),
|
|
133
|
+
# redirection (>, <), and variable expansion ($VAR)
|
|
134
|
+
for c in cmd:
|
|
135
|
+
if c.isalnum() or c in ' .-_/+#,:\'"*':
|
|
136
|
+
continue
|
|
137
|
+
return False, (
|
|
138
|
+
"Command contains disallowed characters. "
|
|
139
|
+
"Only alphanumeric, spaces, and .-_/+#,:,'\"* are permitted."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Extract the base command
|
|
143
|
+
parts = cmd.split()
|
|
144
|
+
if not parts:
|
|
145
|
+
return False, "No command specified"
|
|
146
|
+
|
|
147
|
+
base_cmd = parts[0]
|
|
148
|
+
|
|
149
|
+
# Check if the command is in the allowlist
|
|
150
|
+
# Handle paths like /bin/ls, /usr/bin/cat, etc.
|
|
151
|
+
if "/" in base_cmd:
|
|
152
|
+
base_cmd = base_cmd.rsplit("/", 1)[-1]
|
|
153
|
+
|
|
154
|
+
if base_cmd not in ShellCommandTool.ALLOWED_COMMANDS:
|
|
155
|
+
allowed_list = ", ".join(sorted(ShellCommandTool.ALLOWED_COMMANDS))
|
|
156
|
+
return False, (
|
|
157
|
+
f"Command '{base_cmd}' is not allowed. "
|
|
158
|
+
f"Only these commands are permitted: {allowed_list}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Validate arguments for dangerous patterns, even inside quotes
|
|
162
|
+
for part in parts[1:]:
|
|
163
|
+
# Strip quotes from argument for validation
|
|
164
|
+
clean = part.strip("'\"")
|
|
165
|
+
|
|
166
|
+
# Block path traversal in arguments
|
|
167
|
+
if ".." in clean:
|
|
168
|
+
return False, (
|
|
169
|
+
"Path traversal ('..') is not allowed in command arguments"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Block any dangerous characters that could be used for injection
|
|
173
|
+
# Block any dangerous characters that could be used for injection
|
|
174
|
+
# even inside quoted strings
|
|
175
|
+
if not all(
|
|
176
|
+
c.isalnum() or c in ' .-_/+#,:\'"*-'
|
|
177
|
+
for c in clean
|
|
178
|
+
):
|
|
179
|
+
return False, (
|
|
180
|
+
f"Disallowed characters detected in argument '{part}'"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Block arguments that look like shell patterns for command
|
|
184
|
+
# substitution (contains $ or backtick)
|
|
185
|
+
if "$" in clean or "`" in clean:
|
|
186
|
+
return False, (
|
|
187
|
+
"Shell substitution ($...) or backticks are not allowed"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return True, ""
|
|
191
|
+
|
|
192
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
193
|
+
command = kwargs.get("command")
|
|
194
|
+
timeout = kwargs.get("timeout", 10)
|
|
195
|
+
if not command:
|
|
196
|
+
return ToolResult(
|
|
197
|
+
output="Missing required parameter: command",
|
|
198
|
+
error=True,
|
|
199
|
+
)
|
|
200
|
+
# Coerce timeout to float (LLM may send string like "15")
|
|
201
|
+
try:
|
|
202
|
+
timeout = float(timeout)
|
|
203
|
+
except (TypeError, ValueError):
|
|
204
|
+
timeout = 10.0
|
|
205
|
+
|
|
206
|
+
# Validate command against allowlist
|
|
207
|
+
is_valid, error_msg = self._validate_command(command)
|
|
208
|
+
if not is_valid:
|
|
209
|
+
return ToolResult(
|
|
210
|
+
output=f"Command rejected: {error_msg}",
|
|
211
|
+
error=True,
|
|
212
|
+
exit_code=1,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
start = time.time()
|
|
216
|
+
try:
|
|
217
|
+
result = subprocess.run(
|
|
218
|
+
command.split(),
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
timeout=timeout,
|
|
222
|
+
)
|
|
223
|
+
elapsed = time.time() - start
|
|
224
|
+
return ToolResult(
|
|
225
|
+
output=(
|
|
226
|
+
f"stdout: {result.stdout}\n"
|
|
227
|
+
f"stderr: {result.stderr}\n"
|
|
228
|
+
f"exit_code: {result.returncode}\n"
|
|
229
|
+
f"time: {elapsed:.2f}s"
|
|
230
|
+
),
|
|
231
|
+
error=result.returncode != 0,
|
|
232
|
+
stdout=result.stdout,
|
|
233
|
+
stderr=result.stderr,
|
|
234
|
+
exit_code=result.returncode,
|
|
235
|
+
)
|
|
236
|
+
except subprocess.TimeoutExpired as e:
|
|
237
|
+
elapsed = time.time() - start
|
|
238
|
+
return ToolResult(
|
|
239
|
+
output=(
|
|
240
|
+
f"stdout: {e.stdout or ''}\n"
|
|
241
|
+
f"stderr: {e.stderr or ''}\n"
|
|
242
|
+
f"exit_code: -1\n"
|
|
243
|
+
f"time: timeout after {timeout}s"
|
|
244
|
+
),
|
|
245
|
+
error=True,
|
|
246
|
+
exit_code=-1,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class ReadFileTool(BaseTool):
|
|
251
|
+
"""Read files from the sandbox filesystem."""
|
|
252
|
+
|
|
253
|
+
name = "read_file"
|
|
254
|
+
description = "Read the contents of a file from the sandbox filesystem."
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def params(self) -> list[ToolParam]:
|
|
258
|
+
return [
|
|
259
|
+
ToolParam("path", "string", "Path to the file to read", required=True),
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
263
|
+
path = kwargs.get("path")
|
|
264
|
+
if not path:
|
|
265
|
+
return ToolResult(
|
|
266
|
+
output="Missing required parameter: path",
|
|
267
|
+
error=True,
|
|
268
|
+
)
|
|
269
|
+
# Read from host filesystem (same as browse_dir)
|
|
270
|
+
p = Path(path)
|
|
271
|
+
if not p.exists():
|
|
272
|
+
return ToolResult(
|
|
273
|
+
output=f"File not found: {path}",
|
|
274
|
+
error=True,
|
|
275
|
+
exit_code=1,
|
|
276
|
+
)
|
|
277
|
+
try:
|
|
278
|
+
content = p.read_text()
|
|
279
|
+
return ToolResult(output=f"File: {path}\n{content}", exit_code=0)
|
|
280
|
+
except Exception as e:
|
|
281
|
+
return ToolResult(
|
|
282
|
+
output=f"Error reading file: {str(e)}",
|
|
283
|
+
error=True,
|
|
284
|
+
exit_code=1,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
class FetchGitDiffTool(BaseTool):
|
|
288
|
+
"""Fetch the git diff of the current repository."""
|
|
289
|
+
|
|
290
|
+
name = "fetch_git_diff"
|
|
291
|
+
description = "Fetch the git diff of the current repository."
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def params(self) -> list[ToolParam]:
|
|
295
|
+
return []
|
|
296
|
+
|
|
297
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
298
|
+
try:
|
|
299
|
+
result = subprocess.run(
|
|
300
|
+
["git", "diff"],
|
|
301
|
+
capture_output=True,
|
|
302
|
+
text=True,
|
|
303
|
+
timeout=10,
|
|
304
|
+
)
|
|
305
|
+
if result.returncode != 0:
|
|
306
|
+
return ToolResult(
|
|
307
|
+
output=f"Error fetching git diff: {result.stderr}",
|
|
308
|
+
error=True,
|
|
309
|
+
exit_code=result.returncode,
|
|
310
|
+
)
|
|
311
|
+
return ToolResult(output=result.stdout, exit_code=0)
|
|
312
|
+
except subprocess.TimeoutExpired:
|
|
313
|
+
return ToolResult(
|
|
314
|
+
output="SANDBOX_ERROR: Git diff command timed out",
|
|
315
|
+
error=True,
|
|
316
|
+
exit_code=-1,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
class WriteFileTool(BaseTool):
|
|
320
|
+
"""Write files to the host filesystem."""
|
|
321
|
+
|
|
322
|
+
name = "write_file"
|
|
323
|
+
description = "Write content to a file on the host filesystem."
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def params(self) -> list[ToolParam]:
|
|
327
|
+
return [
|
|
328
|
+
ToolParam("path", "string", "Path to write to", required=True),
|
|
329
|
+
ToolParam("content", "string", "Content to write", required=True),
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
333
|
+
path = kwargs.get("path")
|
|
334
|
+
content = kwargs.get("content")
|
|
335
|
+
if not path:
|
|
336
|
+
return ToolResult(
|
|
337
|
+
output="Missing required parameter: path",
|
|
338
|
+
error=True,
|
|
339
|
+
)
|
|
340
|
+
if not content:
|
|
341
|
+
return ToolResult(
|
|
342
|
+
output="Missing required parameter: content",
|
|
343
|
+
error=True,
|
|
344
|
+
)
|
|
345
|
+
p = Path(path)
|
|
346
|
+
# Block path traversal
|
|
347
|
+
if ".." in p.parts:
|
|
348
|
+
return ToolResult(
|
|
349
|
+
output="Error writing file: Path traversal ('..') is not allowed",
|
|
350
|
+
error=True,
|
|
351
|
+
exit_code=1,
|
|
352
|
+
)
|
|
353
|
+
# Resolve the path to get absolute path
|
|
354
|
+
resolved = p.resolve()
|
|
355
|
+
try:
|
|
356
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
resolved.write_text(content)
|
|
358
|
+
return ToolResult(
|
|
359
|
+
output=f"Written {len(content)} bytes to {path}",
|
|
360
|
+
exit_code=0,
|
|
361
|
+
)
|
|
362
|
+
except Exception as e:
|
|
363
|
+
return ToolResult(
|
|
364
|
+
output=f"Error writing file: {str(e)}",
|
|
365
|
+
error=True,
|
|
366
|
+
exit_code=1,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class PatchFileTool(BaseTool):
|
|
371
|
+
"""Replace a range of lines in an existing file."""
|
|
372
|
+
|
|
373
|
+
name = "patch_file"
|
|
374
|
+
description = (
|
|
375
|
+
"Replace lines start_line through end_line (inclusive, 1-indexed) in a file "
|
|
376
|
+
"with new content. The file must already exist."
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def params(self) -> list[ToolParam]:
|
|
381
|
+
return [
|
|
382
|
+
ToolParam("path", "string", "Path to the file", required=True),
|
|
383
|
+
ToolParam(
|
|
384
|
+
"start_line",
|
|
385
|
+
"integer",
|
|
386
|
+
"Starting line number (1-indexed, inclusive)",
|
|
387
|
+
required=True,
|
|
388
|
+
),
|
|
389
|
+
ToolParam(
|
|
390
|
+
"end_line",
|
|
391
|
+
"integer",
|
|
392
|
+
"Ending line number (1-indexed, inclusive)",
|
|
393
|
+
required=True,
|
|
394
|
+
),
|
|
395
|
+
ToolParam(
|
|
396
|
+
"replacement",
|
|
397
|
+
"string",
|
|
398
|
+
"Text to replace the specified line range with",
|
|
399
|
+
required=True,
|
|
400
|
+
),
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
404
|
+
path = kwargs.get("path")
|
|
405
|
+
start_line = kwargs.get("start_line")
|
|
406
|
+
end_line = kwargs.get("end_line")
|
|
407
|
+
replacement = kwargs.get("replacement")
|
|
408
|
+
|
|
409
|
+
if not path:
|
|
410
|
+
return ToolResult(
|
|
411
|
+
output="Missing required parameter: path",
|
|
412
|
+
error=True,
|
|
413
|
+
)
|
|
414
|
+
if start_line is None:
|
|
415
|
+
return ToolResult(
|
|
416
|
+
output="Missing required parameter: start_line",
|
|
417
|
+
error=True,
|
|
418
|
+
)
|
|
419
|
+
if end_line is None:
|
|
420
|
+
return ToolResult(
|
|
421
|
+
output="Missing required parameter: end_line",
|
|
422
|
+
error=True,
|
|
423
|
+
)
|
|
424
|
+
if replacement is None:
|
|
425
|
+
return ToolResult(
|
|
426
|
+
output="Missing required parameter: replacement",
|
|
427
|
+
error=True,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Coerce line numbers
|
|
431
|
+
try:
|
|
432
|
+
start_line = int(start_line)
|
|
433
|
+
end_line = int(end_line)
|
|
434
|
+
except (TypeError, ValueError):
|
|
435
|
+
return ToolResult(
|
|
436
|
+
output="Error: start_line and end_line must be integers",
|
|
437
|
+
error=True,
|
|
438
|
+
exit_code=1,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if start_line > end_line:
|
|
442
|
+
return ToolResult(
|
|
443
|
+
output="Error: start_line must be <= end_line",
|
|
444
|
+
error=True,
|
|
445
|
+
exit_code=1,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
p = Path(path)
|
|
449
|
+
if ".." in p.parts:
|
|
450
|
+
return ToolResult(
|
|
451
|
+
output="Error: Path traversal ('..') is not allowed",
|
|
452
|
+
error=True,
|
|
453
|
+
exit_code=1,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
resolved = p.resolve()
|
|
457
|
+
if not resolved.exists():
|
|
458
|
+
return ToolResult(
|
|
459
|
+
output=f"File not found: {path}",
|
|
460
|
+
error=True,
|
|
461
|
+
exit_code=1,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
raw_content = resolved.read_text()
|
|
466
|
+
trailing_newline = raw_content.endswith("\n") if raw_content else False
|
|
467
|
+
lines = raw_content.splitlines()
|
|
468
|
+
total = len(lines)
|
|
469
|
+
|
|
470
|
+
if start_line < 1 or end_line > total:
|
|
471
|
+
return ToolResult(
|
|
472
|
+
output=(
|
|
473
|
+
f"Line range {start_line}-{end_line} out of bounds "
|
|
474
|
+
f"for file with {total} lines"
|
|
475
|
+
),
|
|
476
|
+
error=True,
|
|
477
|
+
exit_code=1,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Build new content: before range + replacement + after range
|
|
481
|
+
prefix = "\n".join(lines[: start_line - 1])
|
|
482
|
+
suffix = "\n".join(lines[end_line:])
|
|
483
|
+
|
|
484
|
+
if replacement:
|
|
485
|
+
if prefix and suffix:
|
|
486
|
+
new_content = f"{prefix}\n{replacement}\n{suffix}"
|
|
487
|
+
elif prefix:
|
|
488
|
+
new_content = f"{prefix}\n{replacement}"
|
|
489
|
+
elif suffix:
|
|
490
|
+
new_content = f"{replacement}\n{suffix}"
|
|
491
|
+
else:
|
|
492
|
+
new_content = replacement
|
|
493
|
+
else:
|
|
494
|
+
# Empty replacement: just join prefix and suffix
|
|
495
|
+
if prefix and suffix:
|
|
496
|
+
new_content = f"{prefix}\n{suffix}"
|
|
497
|
+
elif prefix:
|
|
498
|
+
new_content = prefix
|
|
499
|
+
elif suffix:
|
|
500
|
+
new_content = suffix
|
|
501
|
+
else:
|
|
502
|
+
new_content = ""
|
|
503
|
+
|
|
504
|
+
if trailing_newline:
|
|
505
|
+
new_content += "\n"
|
|
506
|
+
|
|
507
|
+
resolved.write_text(new_content)
|
|
508
|
+
return ToolResult(
|
|
509
|
+
output=(
|
|
510
|
+
f"Replaced lines {start_line}-{end_line} in {path}\n"
|
|
511
|
+
f"New content: {replacement}"
|
|
512
|
+
),
|
|
513
|
+
exit_code=0,
|
|
514
|
+
)
|
|
515
|
+
except Exception as e:
|
|
516
|
+
return ToolResult(
|
|
517
|
+
output=f"Error patching file: {str(e)}",
|
|
518
|
+
error=True,
|
|
519
|
+
exit_code=1,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class ListDirTool(BaseTool):
|
|
524
|
+
"""List directory contents from the sandbox filesystem."""
|
|
525
|
+
|
|
526
|
+
name = "list_dir"
|
|
527
|
+
description = "List contents of a directory in the sandbox filesystem."
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def params(self) -> list[ToolParam]:
|
|
531
|
+
return [
|
|
532
|
+
ToolParam(
|
|
533
|
+
"path",
|
|
534
|
+
"string",
|
|
535
|
+
"Directory path to list",
|
|
536
|
+
required=False,
|
|
537
|
+
default=".",
|
|
538
|
+
),
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
542
|
+
path = kwargs.get("path", ".")
|
|
543
|
+
# List from host filesystem
|
|
544
|
+
p = Path(path)
|
|
545
|
+
if not p.exists():
|
|
546
|
+
return ToolResult(
|
|
547
|
+
output=f"Directory not found: {path}",
|
|
548
|
+
error=True,
|
|
549
|
+
exit_code=1,
|
|
550
|
+
)
|
|
551
|
+
if not p.is_dir():
|
|
552
|
+
return ToolResult(
|
|
553
|
+
output=f"Not a directory: {path}",
|
|
554
|
+
error=True,
|
|
555
|
+
exit_code=1,
|
|
556
|
+
)
|
|
557
|
+
try:
|
|
558
|
+
entries = sorted(p.iterdir())
|
|
559
|
+
lines = [f"{e.name}/" if e.is_dir() else e.name for e in entries]
|
|
560
|
+
output_lines = "\n".join(lines)
|
|
561
|
+
if lines:
|
|
562
|
+
output = f"Directory: {path}\n{output_lines}"
|
|
563
|
+
else:
|
|
564
|
+
output = f"Directory: {path}\n(empty)"
|
|
565
|
+
return ToolResult(output=output, exit_code=0)
|
|
566
|
+
except Exception as e:
|
|
567
|
+
return ToolResult(
|
|
568
|
+
output=f"Error listing directory: {str(e)}",
|
|
569
|
+
error=True,
|
|
570
|
+
exit_code=1,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class SearchFilesTool(BaseTool):
|
|
575
|
+
"""Search for files by name pattern."""
|
|
576
|
+
|
|
577
|
+
name = "search_files"
|
|
578
|
+
description = "Search for files matching a pattern in the sandbox filesystem."
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def params(self) -> list[ToolParam]:
|
|
582
|
+
return [
|
|
583
|
+
ToolParam(
|
|
584
|
+
"pattern",
|
|
585
|
+
"string",
|
|
586
|
+
"File name pattern to search for",
|
|
587
|
+
required=True,
|
|
588
|
+
),
|
|
589
|
+
ToolParam(
|
|
590
|
+
"path",
|
|
591
|
+
"string",
|
|
592
|
+
"Directory to search in",
|
|
593
|
+
required=False,
|
|
594
|
+
default=".",
|
|
595
|
+
),
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
599
|
+
pattern = kwargs.get("pattern")
|
|
600
|
+
path = kwargs.get("path", ".")
|
|
601
|
+
if not pattern:
|
|
602
|
+
return ToolResult(
|
|
603
|
+
output="Missing required parameter: pattern",
|
|
604
|
+
error=True,
|
|
605
|
+
)
|
|
606
|
+
# Search on host filesystem
|
|
607
|
+
p = Path(path)
|
|
608
|
+
if not p.exists():
|
|
609
|
+
return ToolResult(
|
|
610
|
+
output=f"Directory not found: {path}",
|
|
611
|
+
error=True,
|
|
612
|
+
exit_code=1,
|
|
613
|
+
)
|
|
614
|
+
try:
|
|
615
|
+
matches = list(p.rglob(pattern))
|
|
616
|
+
results = (
|
|
617
|
+
"\n".join(
|
|
618
|
+
str(m.relative_to(p)) for m in matches
|
|
619
|
+
)
|
|
620
|
+
if matches
|
|
621
|
+
else "(no matches)"
|
|
622
|
+
)
|
|
623
|
+
return ToolResult(
|
|
624
|
+
output=f"Pattern: {pattern}\nPath: {path}\n{results}",
|
|
625
|
+
exit_code=0,
|
|
626
|
+
)
|
|
627
|
+
except Exception as e:
|
|
628
|
+
return ToolResult(
|
|
629
|
+
output=f"Error searching files: {str(e)}",
|
|
630
|
+
error=True,
|
|
631
|
+
exit_code=1,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class BrowseDirTool(BaseTool):
|
|
636
|
+
"""Browse a directory tree and list all files."""
|
|
637
|
+
|
|
638
|
+
name = "browse_dir"
|
|
639
|
+
description = (
|
|
640
|
+
"Recursively list all files and directories starting from a path. "
|
|
641
|
+
"Shows the full directory tree structure. Useful for understanding "
|
|
642
|
+
"the layout of a codebase before reading specific files."
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
@property
|
|
646
|
+
def params(self) -> list[ToolParam]:
|
|
647
|
+
return [
|
|
648
|
+
ToolParam(
|
|
649
|
+
"path",
|
|
650
|
+
"string",
|
|
651
|
+
"Directory path to browse",
|
|
652
|
+
required=False,
|
|
653
|
+
default=".",
|
|
654
|
+
),
|
|
655
|
+
]
|
|
656
|
+
|
|
657
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
658
|
+
path = kwargs.get("path", ".")
|
|
659
|
+
try:
|
|
660
|
+
p = Path(path)
|
|
661
|
+
if not p.exists():
|
|
662
|
+
return ToolResult(
|
|
663
|
+
output=f"Path not found: {path}",
|
|
664
|
+
error=True,
|
|
665
|
+
exit_code=1,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
lines = []
|
|
669
|
+
for root, dirs, files in os.walk(p):
|
|
670
|
+
# Skip hidden dirs and common noise
|
|
671
|
+
dirs[:] = [d for d in dirs if not d.startswith('.')
|
|
672
|
+
and d not in {'__pycache__', '.git', '.venv',
|
|
673
|
+
'node_modules', 'dist', 'build'}]
|
|
674
|
+
rel = os.path.relpath(root, p)
|
|
675
|
+
indent = " " if rel == "." else " " * (rel.count(os.sep) + 1)
|
|
676
|
+
lines.append(f"{indent}{os.path.basename(root)}/")
|
|
677
|
+
for f in sorted(files):
|
|
678
|
+
lines.append(f"{indent} {f}")
|
|
679
|
+
|
|
680
|
+
return ToolResult(
|
|
681
|
+
output=f"Directory: {path}\n" + "\n".join(lines)
|
|
682
|
+
if lines else f"Directory: {path}\n(empty)",
|
|
683
|
+
exit_code=0,
|
|
684
|
+
)
|
|
685
|
+
except Exception as e:
|
|
686
|
+
return ToolResult(
|
|
687
|
+
output=f"Error browsing directory: {str(e)}",
|
|
688
|
+
error=True,
|
|
689
|
+
exit_code=1,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
class SearchDirTool(BaseTool):
|
|
694
|
+
"""Search file contents within a directory tree."""
|
|
695
|
+
|
|
696
|
+
name = "search_dir"
|
|
697
|
+
description = (
|
|
698
|
+
"Search for a pattern inside all files within a directory tree. "
|
|
699
|
+
"Returns matching file paths and line snippets. Useful for finding "
|
|
700
|
+
"specific code patterns, imports, or references across a codebase."
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
@property
|
|
704
|
+
def params(self) -> list[ToolParam]:
|
|
705
|
+
return [
|
|
706
|
+
ToolParam(
|
|
707
|
+
"pattern",
|
|
708
|
+
"string",
|
|
709
|
+
"Text pattern to search for",
|
|
710
|
+
required=True,
|
|
711
|
+
),
|
|
712
|
+
ToolParam(
|
|
713
|
+
"path",
|
|
714
|
+
"string",
|
|
715
|
+
"Directory to search in",
|
|
716
|
+
required=False,
|
|
717
|
+
default=".",
|
|
718
|
+
),
|
|
719
|
+
ToolParam(
|
|
720
|
+
"file_type",
|
|
721
|
+
"string",
|
|
722
|
+
"File extension to filter (e.g. .py, .js). Empty for all files.",
|
|
723
|
+
required=False,
|
|
724
|
+
default="",
|
|
725
|
+
),
|
|
726
|
+
]
|
|
727
|
+
|
|
728
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
729
|
+
pattern = kwargs.get("pattern")
|
|
730
|
+
path = kwargs.get("path", ".")
|
|
731
|
+
file_type = kwargs.get("file_type", "")
|
|
732
|
+
if not pattern:
|
|
733
|
+
return ToolResult(
|
|
734
|
+
output="Missing required parameter: pattern",
|
|
735
|
+
error=True,
|
|
736
|
+
)
|
|
737
|
+
try:
|
|
738
|
+
p = Path(path)
|
|
739
|
+
if not p.exists():
|
|
740
|
+
return ToolResult(
|
|
741
|
+
output=f"Directory not found: {path}",
|
|
742
|
+
error=True,
|
|
743
|
+
exit_code=1,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
matches = []
|
|
747
|
+
for root, dirs, files in os.walk(p):
|
|
748
|
+
# Skip hidden dirs and common noise
|
|
749
|
+
dirs[:] = [d for d in dirs if not d.startswith('.')
|
|
750
|
+
and d not in {'__pycache__', '.git', '.venv',
|
|
751
|
+
'node_modules', 'dist', 'build'}]
|
|
752
|
+
for f in sorted(files):
|
|
753
|
+
if file_type and not f.endswith(file_type):
|
|
754
|
+
continue
|
|
755
|
+
fp = Path(root) / f
|
|
756
|
+
try:
|
|
757
|
+
content = fp.read_text(errors='ignore')
|
|
758
|
+
for line_no, line in enumerate(content.split('\n'), 1):
|
|
759
|
+
if pattern in line:
|
|
760
|
+
matches.append(
|
|
761
|
+
f"{fp}:{line_no}: {line.strip()}"
|
|
762
|
+
)
|
|
763
|
+
except Exception:
|
|
764
|
+
continue
|
|
765
|
+
|
|
766
|
+
if not matches:
|
|
767
|
+
return ToolResult(
|
|
768
|
+
output=(
|
|
769
|
+
f"Search: '{pattern}' in {path} "
|
|
770
|
+
f"(file_type: {file_type or 'all'})\n(no matches)"
|
|
771
|
+
),
|
|
772
|
+
exit_code=0,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
return ToolResult(
|
|
776
|
+
output=(
|
|
777
|
+
f"Search: '{pattern}' in {path} "
|
|
778
|
+
f"(file_type: {file_type or 'all'})\n" +
|
|
779
|
+
"\n".join(matches[:100]) +
|
|
780
|
+
(f"\n... ({len(matches) - 100} more matches)"
|
|
781
|
+
if len(matches) > 100 else "")
|
|
782
|
+
),
|
|
783
|
+
exit_code=0,
|
|
784
|
+
)
|
|
785
|
+
except Exception as e:
|
|
786
|
+
return ToolResult(
|
|
787
|
+
output=f"Error searching directory: {str(e)}",
|
|
788
|
+
error=True,
|
|
789
|
+
exit_code=1,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
class RunPythonScriptTool(BaseTool):
|
|
794
|
+
"""Run a Python script from a file."""
|
|
795
|
+
|
|
796
|
+
name = "run_script"
|
|
797
|
+
description = "Execute a Python script file in the Monty sandbox."
|
|
798
|
+
|
|
799
|
+
@property
|
|
800
|
+
def params(self) -> list[ToolParam]:
|
|
801
|
+
return [
|
|
802
|
+
ToolParam(
|
|
803
|
+
"script_path",
|
|
804
|
+
"string",
|
|
805
|
+
"Path to the Python script file",
|
|
806
|
+
required=True,
|
|
807
|
+
),
|
|
808
|
+
ToolParam(
|
|
809
|
+
"args",
|
|
810
|
+
"string",
|
|
811
|
+
"Command-line arguments for the script",
|
|
812
|
+
required=False,
|
|
813
|
+
default="",
|
|
814
|
+
),
|
|
815
|
+
ToolParam(
|
|
816
|
+
"timeout",
|
|
817
|
+
"number",
|
|
818
|
+
"Execution timeout in seconds",
|
|
819
|
+
required=False,
|
|
820
|
+
default=10,
|
|
821
|
+
),
|
|
822
|
+
]
|
|
823
|
+
|
|
824
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
825
|
+
script_path = kwargs.get("script_path")
|
|
826
|
+
args = kwargs.get("args", "")
|
|
827
|
+
timeout = kwargs.get("timeout", 10)
|
|
828
|
+
if not script_path:
|
|
829
|
+
return ToolResult(
|
|
830
|
+
output="Missing required parameter: script_path",
|
|
831
|
+
error=True,
|
|
832
|
+
)
|
|
833
|
+
sandbox = MontySandbox()
|
|
834
|
+
try:
|
|
835
|
+
script_full = sandbox.sandbox_root / script_path
|
|
836
|
+
if not script_full.exists():
|
|
837
|
+
return ToolResult(
|
|
838
|
+
output=f"Script not found: {script_path}",
|
|
839
|
+
error=True,
|
|
840
|
+
exit_code=1,
|
|
841
|
+
)
|
|
842
|
+
import subprocess
|
|
843
|
+
|
|
844
|
+
cmd = ["python3", str(script_full)]
|
|
845
|
+
if args:
|
|
846
|
+
cmd.extend(args.split())
|
|
847
|
+
|
|
848
|
+
result = subprocess.run(
|
|
849
|
+
cmd,
|
|
850
|
+
cwd=str(sandbox.sandbox_root),
|
|
851
|
+
capture_output=True,
|
|
852
|
+
text=True,
|
|
853
|
+
timeout=timeout,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
return ToolResult(
|
|
857
|
+
output=(
|
|
858
|
+
f"stdout: {result.stdout}\n"
|
|
859
|
+
f"stderr: {result.stderr}\n"
|
|
860
|
+
f"exit_code: {result.returncode}"
|
|
861
|
+
),
|
|
862
|
+
error=result.returncode != 0,
|
|
863
|
+
stdout=result.stdout,
|
|
864
|
+
stderr=result.stderr,
|
|
865
|
+
exit_code=result.returncode,
|
|
866
|
+
)
|
|
867
|
+
except subprocess.TimeoutExpired:
|
|
868
|
+
return ToolResult(
|
|
869
|
+
output="SANDBOX_ERROR: Execution timed out",
|
|
870
|
+
error=True,
|
|
871
|
+
exit_code=-1,
|
|
872
|
+
)
|
|
873
|
+
finally:
|
|
874
|
+
sandbox.cleanup()
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
class SystemInfoTool(BaseTool):
|
|
878
|
+
"""Get system information."""
|
|
879
|
+
|
|
880
|
+
name = "system_info"
|
|
881
|
+
description = "Get system information (OS, Python version, available resources)."
|
|
882
|
+
|
|
883
|
+
@property
|
|
884
|
+
def params(self) -> list[ToolParam]:
|
|
885
|
+
return []
|
|
886
|
+
|
|
887
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
888
|
+
import platform
|
|
889
|
+
import sys
|
|
890
|
+
|
|
891
|
+
try:
|
|
892
|
+
python_version = sys.version
|
|
893
|
+
os_name = platform.system()
|
|
894
|
+
os_release = platform.release()
|
|
895
|
+
architecture = platform.machine()
|
|
896
|
+
cpu_count = os.cpu_count() or 1
|
|
897
|
+
|
|
898
|
+
# Get memory info
|
|
899
|
+
mem_total = 0
|
|
900
|
+
try:
|
|
901
|
+
import psutil
|
|
902
|
+
|
|
903
|
+
mem = psutil.virtual_memory()
|
|
904
|
+
mem_total = mem.total / (1024**3) # GB
|
|
905
|
+
except ImportError:
|
|
906
|
+
try:
|
|
907
|
+
mem_total = _get_mem_from_proc()
|
|
908
|
+
except Exception:
|
|
909
|
+
mem_total = "unknown"
|
|
910
|
+
|
|
911
|
+
info = f"OS: {os_name} {os_release}\n"
|
|
912
|
+
info += f"Architecture: {architecture}\n"
|
|
913
|
+
info += f"Python: {python_version}\n"
|
|
914
|
+
info += f"CPU cores: {cpu_count}\n"
|
|
915
|
+
info += f"Total memory: {mem_total} GB"
|
|
916
|
+
|
|
917
|
+
return ToolResult(output=info, exit_code=0)
|
|
918
|
+
except Exception as e:
|
|
919
|
+
return ToolResult(output=f"Error: {str(e)}", error=True, exit_code=1)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _get_mem_from_proc() -> float:
|
|
923
|
+
"""Get total memory from /proc/meminfo on Linux."""
|
|
924
|
+
with open("/proc/meminfo") as f:
|
|
925
|
+
for line in f:
|
|
926
|
+
if line.startswith("MemTotal:"):
|
|
927
|
+
kb = int(line.split()[1])
|
|
928
|
+
return kb / (1024**2) # Convert KB to GB
|
|
929
|
+
return 0
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def create_default_tools() -> list[BaseTool]:
|
|
933
|
+
"""Create the default set of tools for the agent."""
|
|
934
|
+
return [
|
|
935
|
+
PythonCodeTool(),
|
|
936
|
+
ShellCommandTool(),
|
|
937
|
+
ReadFileTool(),
|
|
938
|
+
WriteFileTool(),
|
|
939
|
+
PatchFileTool(),
|
|
940
|
+
ListDirTool(),
|
|
941
|
+
SearchFilesTool(),
|
|
942
|
+
BrowseDirTool(),
|
|
943
|
+
SearchDirTool(),
|
|
944
|
+
RunPythonScriptTool(),
|
|
945
|
+
SystemInfoTool(),
|
|
946
|
+
FetchGitDiffTool(),
|
|
947
|
+
]
|