Skip to content

Commit 1ab63b7

Browse files
committed
✨ Feat: Enhance Python tool and add logging
- Add logging to the agent to track model responses and tool execution. - Introduce a `_truncate` helper for logging long strings. - Enhance the Python tool to: - Accept a list of extra allowed imports via configuration. - Use AST to parse code and enforce import restrictions. - Add `psutil` to default allowed imports and requirements. - Update `load_default_tools` to accept settings for Python tool imports. - Add verbose and quiet logging options to the CLI. - Update README with examples and configuration details for Python tool imports.
1 parent 5ddeef2 commit 1ab63b7

File tree

9 files changed

+159
-12
lines changed

9 files changed

+159
-12
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ GEMINI_API_KEY=your-gemini-key
55
GEMINI_MODEL=gemini-2.5-flash
66
AGENT_SYSTEM_PROMPT=You are a concise assistant. Use tools when needed.
77
REQUEST_TIMEOUT=30
8+
PYTHON_TOOL_IMPORTS=os,sys,psutil

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@ The repo ships with a lightweight `Makefile` wired to [`uvx`](https://docs.astra
2424
make run PROMPT="Summarize the latest message."
2525
```
2626

27+
### Examples
28+
29+
#### Disk space:
30+
31+
```bash
32+
| => python main.py "get disk space on current machine using python"
33+
Disk Space:
34+
Total: 926.35 GB
35+
Used: 452.60 GB
36+
Free: 473.76 GB
37+
```
38+
39+
#### CPU usage:
40+
41+
```bash
42+
| => python main.py "give current cpu usage" -v
43+
INFO: Running tool 'python'.
44+
INFO: Responding without tool use.
45+
Current CPU usage: 16.5%
46+
```
47+
2748
### Configuration
2849

2950
All settings live in `.env` (loaded with `python-dotenv`):
@@ -37,6 +58,7 @@ All settings live in `.env` (loaded with `python-dotenv`):
3758
| `GEMINI_MODEL` | Defaults to `gemini-2.5-flash`. |
3859
| `AGENT_SYSTEM_PROMPT` | Optional custom system prompt. |
3960
| `REQUEST_TIMEOUT` | Request timeout in seconds (default `30`). |
61+
| `PYTHON_TOOL_IMPORTS` | Optional comma list of extra python-tool imports (`os,sys,psutil`). |
4062

4163
The repository already contains `.env.example` with placeholders for these values.
4264

@@ -51,6 +73,8 @@ python main.py --help
5173
- `--max-turns`: maximum number of tool iterations.
5274
- `--no-tools`: disable tool use.
5375
- `--list-tools`: inspect available tools.
76+
- `-v/--verbose`: increase logging (use `-vv` for debug-level traces about tool usage).
77+
- `-q/--quiet`: suppress logs (errors only).
5478

5579
### Tools
5680

@@ -59,7 +83,9 @@ Tools live under `simple_agent/tools` and implement a tiny interface (`name`, `d
5983
- `time`: returns the current UTC timestamp.
6084
- `calculator`: evaluates small arithmetic expressions safely.
6185
- `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).
86+
- `python`: runs a short Python snippet in a separate interpreter (default imports include `math`, `json`, `os`, `sys`, `psutil`; extend via `PYTHON_TOOL_IMPORTS`).
87+
88+
The python tool executes with a module allowlist. By default it includes: `collections`, `datetime`, `functools`, `itertools`, `json`, `math`, `os`, `pathlib`, `psutil`, `random`, `statistics`, `sys`, `time`. Set `PYTHON_TOOL_IMPORTS` (comma separated) to append additional modules if needed.
6389

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

main.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,60 @@
33
from __future__ import annotations
44

55
import argparse
6+
import logging
67
from dataclasses import replace
78

89
from simple_agent import SimpleAgent, get_backend, load_default_tools
910
from simple_agent.config import get_settings
1011

1112

13+
def configure_logging(verbosity: int, quiet: bool) -> None:
14+
if quiet:
15+
level = logging.ERROR
16+
else:
17+
if verbosity >= 2:
18+
level = logging.DEBUG
19+
elif verbosity == 1:
20+
level = logging.INFO
21+
else:
22+
level = logging.WARNING
23+
24+
logging.basicConfig(level=level, format="%(levelname)s: %(message)s", force=True)
25+
26+
1227
def build_parser() -> argparse.ArgumentParser:
1328
parser = argparse.ArgumentParser(description="Run a small tool-enabled agent.")
1429
parser.add_argument("prompt", nargs="?", help="Prompt to send to the agent. If omitted, stdin is used.")
1530
parser.add_argument("--backend", choices=["chatgpt", "gemini"], help="Override the backend specified in .env.")
1631
parser.add_argument("--max-turns", type=int, default=5, help="Maximum number of tool loops before giving up.")
1732
parser.add_argument("--no-tools", action="store_true", help="Disable tool usage and respond directly.")
1833
parser.add_argument("--list-tools", action="store_true", help="List available tools and exit.")
34+
parser.add_argument(
35+
"-v",
36+
"--verbose",
37+
action="count",
38+
default=0,
39+
help="Increase log verbosity (use -vv for debug).",
40+
)
41+
parser.add_argument(
42+
"-q",
43+
"--quiet",
44+
action="store_true",
45+
help="Quiet mode (errors only). Overrides --verbose.",
46+
)
1947
return parser
2048

2149

2250
def main() -> None:
2351
parser = build_parser()
2452
args = parser.parse_args()
53+
configure_logging(args.verbose, args.quiet)
54+
55+
settings = get_settings()
56+
if args.backend:
57+
settings = replace(settings, backend=args.backend) # type: ignore[arg-type]
2558

26-
tools = [] if args.no_tools else load_default_tools()
59+
tools = [] if args.no_tools else load_default_tools(settings)
2760

2861
if args.list_tools:
2962
if not tools:
@@ -43,10 +76,6 @@ def main() -> None:
4376
if not prompt:
4477
parser.error("A prompt is required.")
4578

46-
settings = get_settings()
47-
if args.backend:
48-
settings = replace(settings, backend=args.backend) # type: ignore[arg-type]
49-
5079
backend = get_backend(settings)
5180
agent = SimpleAgent(backend=backend, tools=tools, system_prompt=settings.system_prompt)
5281
try:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ requires-python = ">=3.11"
88
dependencies = [
99
"python-dotenv>=1.0",
1010
"requests>=2.32",
11+
"psutil>=5.9",
1112
]
1213

1314
[project.optional-dependencies]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
python-dotenv>=1.0
22
requests>=2.32
3+
psutil>=5.9

simple_agent/agent.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import logging
67
import re
78
from dataclasses import dataclass, field
89
from typing import Dict, Iterable, List
@@ -18,7 +19,11 @@
1819
If a tool is necessary, respond with ONLY a JSON object that looks like:
1920
{{"tool": "<tool name>", "input": "<plain text request for the tool>"}}
2021
Do not wrap the JSON in backticks or add commentary.
21-
If no tool is needed, answer the user directly in natural language."""
22+
If no tool is needed, answer the user directly in natural language.
23+
<IMPORTANT>
24+
If no explicit tool try to use python tool to execute code in it!
25+
</IMPORTANT>
26+
"""
2227

2328

2429
@dataclass(slots=True)
@@ -30,6 +35,7 @@ class SimpleAgent:
3035
system_prompt: str
3136
tool_map: Dict[str, Tool] = field(init=False)
3237
_prepared_system_prompt: str = field(init=False)
38+
_logger: logging.Logger = field(init=False, repr=False)
3339

3440
def __post_init__(self) -> None:
3541
self.tool_map: Dict[str, Tool] = {tool.name: tool for tool in self.tools}
@@ -38,6 +44,7 @@ def __post_init__(self) -> None:
3844
user_prompt=self.system_prompt,
3945
tool_descriptions=descriptions,
4046
)
47+
self._logger = logging.getLogger(self.__class__.__name__)
4148

4249
def run(self, user_input: str, max_turns: int = 5) -> str:
4350
history: List[dict[str, str]] = [
@@ -47,15 +54,18 @@ def run(self, user_input: str, max_turns: int = 5) -> str:
4754

4855
for _ in range(max_turns):
4956
response = self.backend.generate(history)
57+
self._logger.debug("Model response: %s", _truncate(response))
5058
tool_request = self._maybe_extract_tool_request(response)
5159
if not tool_request:
60+
self._logger.info("Responding without tool use.")
5261
return response.strip()
5362

5463
tool_name = tool_request.get("tool")
5564
tool_input = tool_request.get("input", "")
5665

5766
tool = self.tool_map.get(tool_name or "")
5867
if not tool:
68+
self._logger.warning("Model requested unknown tool '%s'.", tool_name)
5969
history.append(
6070
{
6171
"role": "user",
@@ -64,7 +74,11 @@ def run(self, user_input: str, max_turns: int = 5) -> str:
6474
)
6575
continue
6676

77+
self._logger.info("Running tool '%s'.", tool_name)
78+
if tool_input:
79+
self._logger.debug("Tool '%s' input: %s", tool_name, _truncate(tool_input))
6780
tool_output = tool.run(tool_input)
81+
self._logger.debug("Tool '%s' output: %s", tool_name, _truncate(tool_output))
6882

6983
history.append({"role": "assistant", "content": json.dumps(tool_request)})
7084
history.append(
@@ -93,3 +107,8 @@ def _maybe_extract_tool_request(text: str) -> dict | None:
93107
return data
94108

95109
return None
110+
111+
112+
def _truncate(value: str, limit: int = 500) -> str:
113+
value = value.strip()
114+
return value if len(value) <= limit else f"{value[:limit]}…"

simple_agent/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Settings:
2626
gemini_api_key: str | None
2727
gemini_model: str
2828
request_timeout: float
29+
python_tool_imports: tuple[str, ...]
2930

3031
@staticmethod
3132
def _get_env(key: str, default: str | None = None) -> str | None:
@@ -51,6 +52,7 @@ def from_env(cls) -> "Settings":
5152
gemini_api_key=cls._get_env("GEMINI_API_KEY"),
5253
gemini_model=cls._get_env("GEMINI_MODEL", "gemini-1.5-flash"),
5354
request_timeout=float(cls._get_env("REQUEST_TIMEOUT", "30")),
55+
python_tool_imports=_parse_list(cls._get_env("PYTHON_TOOL_IMPORTS")),
5456
)
5557

5658

@@ -59,3 +61,9 @@ def get_settings() -> Settings:
5961
"""Convenience accessor with caching to avoid redundant parsing."""
6062

6163
return Settings.from_env()
64+
65+
66+
def _parse_list(value: str | None) -> tuple[str, ...]:
67+
if not value:
68+
return ()
69+
return tuple(item.strip() for item in value.split(",") if item.strip())

simple_agent/tools/__init__.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,31 @@
22

33
from __future__ import annotations
44

5-
from typing import List
5+
from typing import List, TYPE_CHECKING
66

77
from .base import Tool
88
from .file_read_tool import FileReadTool
99
from .math_tool import MathTool
10-
from .time_tool import TimeTool
1110
from .python_tool import PythonSandboxTool
11+
from .time_tool import TimeTool
1212

13+
if TYPE_CHECKING: # pragma: no cover
14+
from ..config import Settings
1315

14-
def load_default_tools() -> List[Tool]:
16+
17+
def load_default_tools(settings: "Settings | None" = None) -> List[Tool]:
1518
"""Return the default toolset used by the CLI."""
1619

17-
return [TimeTool(), MathTool(), FileReadTool(), PythonSandboxTool()]
20+
allowed_imports = None
21+
if settings and settings.python_tool_imports:
22+
allowed_imports = set(settings.python_tool_imports)
23+
24+
return [
25+
TimeTool(),
26+
MathTool(),
27+
FileReadTool(),
28+
PythonSandboxTool(extra_allowed_imports=allowed_imports),
29+
]
1830

1931

2032
__all__ = ["Tool", "load_default_tools"]

simple_agent/tools/python_tool.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import subprocess
6+
import ast
67
from textwrap import dedent
78

89
from .base import SimpleTool
@@ -11,18 +12,44 @@
1112
class PythonSandboxTool(SimpleTool):
1213
"""Executes small Python snippets in a separate interpreter."""
1314

14-
def __init__(self, *, timeout: int = 5) -> None:
15+
def __init__(
16+
self,
17+
*,
18+
timeout: int = 5,
19+
extra_allowed_imports: set[str] | None = None,
20+
) -> None:
1521
super().__init__(
1622
name="python",
1723
description="Run Python code in a sandboxed interpreter. Provide raw code; stdout is returned.",
1824
)
1925
self.timeout = timeout
26+
default_allowed = {
27+
"math",
28+
"statistics",
29+
"datetime",
30+
"time",
31+
"random",
32+
"json",
33+
"collections",
34+
"itertools",
35+
"functools",
36+
"os",
37+
"sys",
38+
"pathlib",
39+
"psutil",
40+
}
41+
self.allowed_imports = default_allowed | (extra_allowed_imports or set())
2042

2143
def run(self, query: str) -> str:
2244
code = query.strip()
2345
if not code:
2446
return "Provide Python code to run."
2547

48+
disallowed = _find_disallowed_imports(code, self.allowed_imports)
49+
if disallowed:
50+
allowed = ", ".join(sorted(self.allowed_imports))
51+
return f"Imports not permitted: {', '.join(sorted(disallowed))}. Allowed modules: {allowed}."
52+
2653
wrapped = dedent(
2754
f"""
2855
import sys
@@ -62,3 +89,26 @@ def run(self, query: str) -> str:
6289
if stderr:
6390
return f"[stderr]\n{stderr}"
6491
return stdout or "(no output)"
92+
93+
94+
def _find_disallowed_imports(code: str, allowed: set[str]) -> set[str]:
95+
try:
96+
tree = ast.parse(code)
97+
except SyntaxError:
98+
return set()
99+
100+
blocked: set[str] = set()
101+
for node in ast.walk(tree):
102+
if isinstance(node, ast.Import):
103+
for alias in node.names:
104+
root = alias.name.split(".")[0]
105+
if root not in allowed:
106+
blocked.add(root)
107+
elif isinstance(node, ast.ImportFrom):
108+
if node.module is None or node.level:
109+
blocked.add("<relative>")
110+
continue
111+
root = node.module.split(".")[0]
112+
if root not in allowed:
113+
blocked.add(root)
114+
return blocked

0 commit comments

Comments
 (0)