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.
@@ -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
+ ]