Skip to content

Commit 5ddeef2

Browse files
committed
✨ Feat: Add file_reader and python tools
- Implemented a new FileReadTool for safely reading local text files. - Added a PythonSandboxTool to execute Python snippets in a sandboxed environment. - Updated the README.md to include the new tools and their descriptions. - Registered both new tools in the load_default_tools function.
1 parent eced7a5 commit 5ddeef2

File tree

4 files changed

+153
-1
lines changed

4 files changed

+153
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Tools live under `simple_agent/tools` and implement a tiny interface (`name`, `d
5959
- `time`: returns the current UTC timestamp.
6060
- `calculator`: evaluates small arithmetic expressions safely.
6161
- `file_reader`: dumps a snippet of a local text file (`path[:start-end]`).
62+
- `python`: runs a short Python snippet in a separate interpreter (stdout/stderr returned).
6263

6364
Adding new tools only requires dropping a module next to the others and including it in `load_default_tools()`.
6465

simple_agent/tools/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
from .file_read_tool import FileReadTool
99
from .math_tool import MathTool
1010
from .time_tool import TimeTool
11+
from .python_tool import PythonSandboxTool
1112

1213

1314
def load_default_tools() -> List[Tool]:
1415
"""Return the default toolset used by the CLI."""
1516

16-
return [TimeTool(), MathTool(), FileReadTool()]
17+
return [TimeTool(), MathTool(), FileReadTool(), PythonSandboxTool()]
1718

1819

1920
__all__ = ["Tool", "load_default_tools"]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Tool for reading snippets of local files safely."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from .base import SimpleTool
8+
9+
10+
class FileReadTool(SimpleTool):
11+
"""Reads text files relative to the repo, optionally with a line range."""
12+
13+
def __init__(self, base_dir: Path | None = None, *, max_chars: int = 4000) -> None:
14+
super().__init__(
15+
name="file_reader",
16+
description="Read a local text file. Format: 'path/to/file[:start-end]'.",
17+
)
18+
self.base_dir = Path(base_dir or Path.cwd()).resolve()
19+
self.max_chars = max_chars
20+
21+
def run(self, query: str) -> str:
22+
query = query.strip()
23+
if not query:
24+
return "Provide a relative path, optionally with :start-end for line numbers."
25+
26+
path_str, sep, range_str = query.partition(":")
27+
target = (self.base_dir / path_str).resolve()
28+
29+
if not str(target).startswith(str(self.base_dir)):
30+
return "Refusing to read outside the project directory."
31+
if not target.exists():
32+
return f"File not found: {path_str}"
33+
if target.is_dir():
34+
return f"'{path_str}' is a directory."
35+
36+
try:
37+
text = target.read_text(encoding="utf-8", errors="replace")
38+
except UnicodeDecodeError:
39+
return "File does not appear to be UTF-8 text."
40+
41+
lines = text.splitlines()
42+
snippet: str
43+
44+
if sep:
45+
start_line, end_line = _parse_range(range_str)
46+
if start_line is None:
47+
return "Invalid range. Use integers like :10-30."
48+
start_idx = max(start_line - 1, 0)
49+
end_idx = end_line if end_line is not None else start_idx + 40
50+
snippet_lines = lines[start_idx:end_idx]
51+
snippet = "\n".join(snippet_lines)
52+
else:
53+
snippet = "\n".join(lines[:80])
54+
55+
snippet = snippet.strip()
56+
if len(snippet) > self.max_chars:
57+
snippet = f"{snippet[: self.max_chars]}…"
58+
59+
return f"{path_str}:\n{snippet or '(file empty)'}"
60+
61+
62+
def _parse_range(range_str: str) -> tuple[int | None, int | None]:
63+
if not range_str:
64+
return None, None
65+
parts = range_str.split("-")
66+
if len(parts) == 1:
67+
try:
68+
start = int(parts[0])
69+
except ValueError:
70+
return None, None
71+
return start, start + 40
72+
73+
start_str, end_str = parts[0], parts[1]
74+
try:
75+
start = int(start_str) if start_str else 1
76+
except ValueError:
77+
return None, None
78+
if end_str:
79+
try:
80+
end = int(end_str)
81+
except ValueError:
82+
return None, None
83+
else:
84+
end = start + 40
85+
86+
return start, end

simple_agent/tools/python_tool.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Sandboxed Python execution tool."""
2+
3+
from __future__ import annotations
4+
5+
import subprocess
6+
from textwrap import dedent
7+
8+
from .base import SimpleTool
9+
10+
11+
class PythonSandboxTool(SimpleTool):
12+
"""Executes small Python snippets in a separate interpreter."""
13+
14+
def __init__(self, *, timeout: int = 5) -> None:
15+
super().__init__(
16+
name="python",
17+
description="Run Python code in a sandboxed interpreter. Provide raw code; stdout is returned.",
18+
)
19+
self.timeout = timeout
20+
21+
def run(self, query: str) -> str:
22+
code = query.strip()
23+
if not code:
24+
return "Provide Python code to run."
25+
26+
wrapped = dedent(
27+
f"""
28+
import sys
29+
30+
namespace = {{}}
31+
code = {code!r}
32+
try:
33+
exec(code, namespace, namespace)
34+
except SystemExit as exc:
35+
print(f"[SystemExit] {{exc}}", file=sys.stderr)
36+
except Exception as exc: # pylint: disable=broad-except
37+
print(f"[Error] {{exc}}", file=sys.stderr)
38+
"""
39+
).strip()
40+
41+
try:
42+
completed = subprocess.run(
43+
["python3", "-c", wrapped],
44+
capture_output=True,
45+
text=True,
46+
timeout=self.timeout,
47+
check=False,
48+
)
49+
except subprocess.TimeoutExpired:
50+
return "Python execution timed out."
51+
except Exception as exc: # pylint: disable=broad-except
52+
return f"Failed to invoke python: {exc}"
53+
54+
stdout = completed.stdout.strip()
55+
stderr = completed.stderr.strip()
56+
57+
if completed.returncode != 0 and stderr:
58+
return f"Python exited with {completed.returncode}: {stderr}"
59+
60+
if stderr and stdout:
61+
return f"{stdout}\n[stderr]\n{stderr}"
62+
if stderr:
63+
return f"[stderr]\n{stderr}"
64+
return stdout or "(no output)"

0 commit comments

Comments
 (0)