mageagent-local 2.0.1

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,453 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tool Executor - Actually executes tools and returns real results
4
+
5
+ This module bridges the gap between LLM tool calls and actual execution.
6
+ Instead of hallucinating file contents, it ACTUALLY reads files.
7
+ Instead of making up command output, it ACTUALLY runs commands.
8
+ """
9
+
10
+ import subprocess
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Dict, Any, Optional
15
+ import logging
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ToolExecutor:
21
+ """Execute tools and return real results - no hallucination"""
22
+
23
+ # Dangerous commands that should never be executed
24
+ DANGEROUS_PATTERNS = [
25
+ "rm -rf /",
26
+ "rm -rf /*",
27
+ "mkfs",
28
+ "> /dev/sda",
29
+ "dd if=/dev/zero",
30
+ "dd if=/dev/random",
31
+ ":(){:|:&};:", # Fork bomb
32
+ "chmod -R 777 /",
33
+ "chown -R",
34
+ "sudo rm",
35
+ "wget | sh",
36
+ "curl | sh",
37
+ "wget | bash",
38
+ "curl | bash",
39
+ ]
40
+
41
+ # Maximum file size to read (50KB)
42
+ MAX_FILE_SIZE = 50000
43
+
44
+ # Maximum command output size (10KB)
45
+ MAX_OUTPUT_SIZE = 10000
46
+
47
+ # Command timeout in seconds
48
+ COMMAND_TIMEOUT = 30
49
+
50
+ # Maximum number of glob results
51
+ MAX_GLOB_RESULTS = 100
52
+
53
+ # Maximum number of grep matches
54
+ MAX_GREP_MATCHES = 50
55
+
56
+ def __init__(self, working_dir: Optional[str] = None):
57
+ """Initialize with optional working directory"""
58
+ self.working_dir = working_dir or os.getcwd()
59
+
60
+ def execute(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
61
+ """
62
+ Execute a single tool call and return the result.
63
+
64
+ Args:
65
+ tool_call: Dict with 'tool' (str) and 'arguments' (dict)
66
+
67
+ Returns:
68
+ Dict with result or error
69
+ """
70
+ tool = tool_call.get("tool", "").strip()
71
+ args = tool_call.get("arguments", {})
72
+
73
+ logger.info(f"Executing tool: {tool} with args: {args}")
74
+
75
+ try:
76
+ if tool == "Read":
77
+ return self._read_file(args.get("file_path", ""))
78
+ elif tool == "Write":
79
+ return self._write_file(
80
+ args.get("file_path", ""),
81
+ args.get("content", "")
82
+ )
83
+ elif tool == "Edit":
84
+ return self._edit_file(
85
+ args.get("file_path", ""),
86
+ args.get("old_string", ""),
87
+ args.get("new_string", "")
88
+ )
89
+ elif tool == "Bash":
90
+ return self._run_bash(args.get("command", ""))
91
+ elif tool == "Glob":
92
+ return self._glob_files(
93
+ args.get("pattern", "*"),
94
+ args.get("path", self.working_dir)
95
+ )
96
+ elif tool == "Grep":
97
+ return self._grep_search(
98
+ args.get("pattern", ""),
99
+ args.get("path", self.working_dir)
100
+ )
101
+ elif tool == "WebSearch":
102
+ return self._web_search(args.get("query", ""))
103
+ elif tool == "WebFetch":
104
+ return self._web_fetch(
105
+ args.get("url", ""),
106
+ args.get("prompt", "Summarize this page")
107
+ )
108
+ else:
109
+ return {"error": f"Unknown tool: {tool}", "available_tools": [
110
+ "Read", "Write", "Edit", "Bash", "Glob", "Grep", "WebSearch", "WebFetch"
111
+ ]}
112
+ except Exception as e:
113
+ logger.error(f"Tool execution error: {e}")
114
+ return {"error": str(e), "tool": tool}
115
+
116
+ def _read_file(self, path: str) -> Dict[str, Any]:
117
+ """Actually read a file from the filesystem"""
118
+ if not path:
119
+ return {"error": "No file path provided"}
120
+
121
+ p = Path(path).expanduser().resolve()
122
+
123
+ if not p.exists():
124
+ return {"error": f"File not found: {path}"}
125
+
126
+ if not p.is_file():
127
+ return {"error": f"Not a file: {path}"}
128
+
129
+ # Check file size
130
+ size = p.stat().st_size
131
+ if size > self.MAX_FILE_SIZE:
132
+ return {
133
+ "error": f"File too large ({size} bytes). Max: {self.MAX_FILE_SIZE} bytes",
134
+ "suggestion": "Use Bash with head/tail to read portions"
135
+ }
136
+
137
+ try:
138
+ content = p.read_text(encoding='utf-8')
139
+ return {
140
+ "content": content,
141
+ "path": str(p),
142
+ "size": len(content),
143
+ "lines": content.count('\n') + 1
144
+ }
145
+ except UnicodeDecodeError:
146
+ # Try reading as binary and report
147
+ return {
148
+ "error": "Binary file cannot be read as text",
149
+ "path": str(p),
150
+ "size": size
151
+ }
152
+
153
+ def _write_file(self, path: str, content: str) -> Dict[str, Any]:
154
+ """Actually write content to a file"""
155
+ if not path:
156
+ return {"error": "No file path provided"}
157
+
158
+ if not content:
159
+ return {"error": "No content provided"}
160
+
161
+ p = Path(path).expanduser().resolve()
162
+
163
+ # Security: Don't write outside of home or working directory
164
+ home = Path.home()
165
+ if not (str(p).startswith(str(home)) or str(p).startswith(self.working_dir)):
166
+ return {"error": f"Cannot write outside home directory or working directory"}
167
+
168
+ try:
169
+ p.parent.mkdir(parents=True, exist_ok=True)
170
+ p.write_text(content, encoding='utf-8')
171
+ return {
172
+ "success": True,
173
+ "path": str(p),
174
+ "size": len(content),
175
+ "lines": content.count('\n') + 1
176
+ }
177
+ except PermissionError:
178
+ return {"error": f"Permission denied: {path}"}
179
+ except Exception as e:
180
+ return {"error": f"Write failed: {e}"}
181
+
182
+ def _edit_file(self, path: str, old_string: str, new_string: str) -> Dict[str, Any]:
183
+ """Actually edit a file by replacing text"""
184
+ if not path:
185
+ return {"error": "No file path provided"}
186
+
187
+ if not old_string:
188
+ return {"error": "No old_string provided"}
189
+
190
+ p = Path(path).expanduser().resolve()
191
+
192
+ if not p.exists():
193
+ return {"error": f"File not found: {path}"}
194
+
195
+ try:
196
+ content = p.read_text(encoding='utf-8')
197
+
198
+ if old_string not in content:
199
+ return {
200
+ "error": "old_string not found in file",
201
+ "suggestion": "Check exact whitespace and characters"
202
+ }
203
+
204
+ # Count occurrences
205
+ count = content.count(old_string)
206
+ if count > 1:
207
+ return {
208
+ "error": f"old_string found {count} times. Must be unique.",
209
+ "suggestion": "Provide more context to make it unique"
210
+ }
211
+
212
+ new_content = content.replace(old_string, new_string, 1)
213
+ p.write_text(new_content, encoding='utf-8')
214
+
215
+ return {
216
+ "success": True,
217
+ "path": str(p),
218
+ "replaced": True
219
+ }
220
+ except Exception as e:
221
+ return {"error": f"Edit failed: {e}"}
222
+
223
+ def _run_bash(self, command: str) -> Dict[str, Any]:
224
+ """Actually run a bash command"""
225
+ if not command:
226
+ return {"error": "No command provided"}
227
+
228
+ # Security: Block dangerous commands
229
+ command_lower = command.lower()
230
+ for pattern in self.DANGEROUS_PATTERNS:
231
+ if pattern.lower() in command_lower:
232
+ return {
233
+ "error": f"Command blocked for safety: contains '{pattern}'",
234
+ "blocked": True
235
+ }
236
+
237
+ try:
238
+ result = subprocess.run(
239
+ command,
240
+ shell=True,
241
+ capture_output=True,
242
+ text=True,
243
+ timeout=self.COMMAND_TIMEOUT,
244
+ cwd=self.working_dir,
245
+ env={**os.environ, "HOME": str(Path.home())}
246
+ )
247
+
248
+ stdout = result.stdout[:self.MAX_OUTPUT_SIZE]
249
+ stderr = result.stderr[:2000]
250
+
251
+ truncated = len(result.stdout) > self.MAX_OUTPUT_SIZE
252
+
253
+ return {
254
+ "stdout": stdout,
255
+ "stderr": stderr if stderr else None,
256
+ "returncode": result.returncode,
257
+ "success": result.returncode == 0,
258
+ "truncated": truncated
259
+ }
260
+ except subprocess.TimeoutExpired:
261
+ return {
262
+ "error": f"Command timed out after {self.COMMAND_TIMEOUT} seconds",
263
+ "timeout": True
264
+ }
265
+ except Exception as e:
266
+ return {"error": f"Command failed: {e}"}
267
+
268
+ def _glob_files(self, pattern: str, path: str) -> Dict[str, Any]:
269
+ """Actually find files matching a pattern"""
270
+ if not pattern:
271
+ return {"error": "No pattern provided"}
272
+
273
+ p = Path(path).expanduser().resolve()
274
+
275
+ if not p.exists():
276
+ return {"error": f"Path not found: {path}"}
277
+
278
+ try:
279
+ matches = list(p.glob(pattern))[:self.MAX_GLOB_RESULTS]
280
+
281
+ files = []
282
+ dirs = []
283
+ for m in matches:
284
+ if m.is_file():
285
+ files.append(str(m))
286
+ elif m.is_dir():
287
+ dirs.append(str(m))
288
+
289
+ return {
290
+ "files": files,
291
+ "directories": dirs,
292
+ "total": len(files) + len(dirs),
293
+ "truncated": len(list(p.glob(pattern))) > self.MAX_GLOB_RESULTS
294
+ }
295
+ except Exception as e:
296
+ return {"error": f"Glob failed: {e}"}
297
+
298
+ def _grep_search(self, pattern: str, path: str) -> Dict[str, Any]:
299
+ """Actually search file contents"""
300
+ if not pattern:
301
+ return {"error": "No pattern provided"}
302
+
303
+ p = Path(path).expanduser().resolve()
304
+
305
+ if not p.exists():
306
+ return {"error": f"Path not found: {path}"}
307
+
308
+ try:
309
+ # Use grep for efficiency
310
+ result = subprocess.run(
311
+ ["grep", "-r", "-n", "-l", "--include=*", pattern, str(p)],
312
+ capture_output=True,
313
+ text=True,
314
+ timeout=self.COMMAND_TIMEOUT
315
+ )
316
+
317
+ matches = [m for m in result.stdout.strip().split("\n") if m][:self.MAX_GREP_MATCHES]
318
+
319
+ return {
320
+ "matches": matches,
321
+ "count": len(matches),
322
+ "pattern": pattern,
323
+ "path": str(p)
324
+ }
325
+ except subprocess.TimeoutExpired:
326
+ return {"error": "Search timed out", "timeout": True}
327
+ except Exception as e:
328
+ return {"error": f"Search failed: {e}"}
329
+
330
+ def _web_search(self, query: str) -> Dict[str, Any]:
331
+ """Actually search the web using DuckDuckGo (no API key needed)"""
332
+ if not query:
333
+ return {"error": "No query provided"}
334
+
335
+ try:
336
+ from duckduckgo_search import DDGS
337
+
338
+ with DDGS() as ddgs:
339
+ results = list(ddgs.text(query, max_results=5))
340
+
341
+ return {
342
+ "results": [
343
+ {
344
+ "title": r.get("title", ""),
345
+ "url": r.get("href", ""),
346
+ "snippet": r.get("body", "")
347
+ }
348
+ for r in results
349
+ ],
350
+ "query": query,
351
+ "count": len(results)
352
+ }
353
+ except ImportError:
354
+ return {
355
+ "error": "duckduckgo_search not installed",
356
+ "fix": "pip install duckduckgo_search"
357
+ }
358
+ except Exception as e:
359
+ return {"error": f"Web search failed: {e}"}
360
+
361
+ def _web_fetch(self, url: str, prompt: str) -> Dict[str, Any]:
362
+ """Actually fetch and process a web page"""
363
+ if not url:
364
+ return {"error": "No URL provided"}
365
+
366
+ try:
367
+ import requests
368
+ from bs4 import BeautifulSoup
369
+
370
+ response = requests.get(url, timeout=10, headers={
371
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
372
+ })
373
+ response.raise_for_status()
374
+
375
+ soup = BeautifulSoup(response.text, 'html.parser')
376
+
377
+ # Remove script and style elements
378
+ for script in soup(["script", "style", "nav", "footer", "header"]):
379
+ script.decompose()
380
+
381
+ text = soup.get_text(separator='\n', strip=True)
382
+
383
+ # Truncate if too long
384
+ if len(text) > self.MAX_FILE_SIZE:
385
+ text = text[:self.MAX_FILE_SIZE] + "\n... [truncated]"
386
+
387
+ return {
388
+ "content": text,
389
+ "url": url,
390
+ "title": soup.title.string if soup.title else None,
391
+ "length": len(text)
392
+ }
393
+ except ImportError:
394
+ return {
395
+ "error": "requests or beautifulsoup4 not installed",
396
+ "fix": "pip install requests beautifulsoup4"
397
+ }
398
+ except Exception as e:
399
+ return {"error": f"Web fetch failed: {e}"}
400
+
401
+
402
+ def execute_tool_calls(tool_calls: list, working_dir: Optional[str] = None) -> list:
403
+ """
404
+ Execute a list of tool calls and return results.
405
+
406
+ Args:
407
+ tool_calls: List of {"tool": str, "arguments": dict}
408
+ working_dir: Optional working directory
409
+
410
+ Returns:
411
+ List of {"tool": str, "arguments": dict, "result": dict}
412
+ """
413
+ executor = ToolExecutor(working_dir)
414
+ results = []
415
+
416
+ for tc in tool_calls:
417
+ result = executor.execute(tc)
418
+ results.append({
419
+ "tool": tc.get("tool"),
420
+ "arguments": tc.get("arguments"),
421
+ "result": result
422
+ })
423
+
424
+ return results
425
+
426
+
427
+ # Simple test
428
+ if __name__ == "__main__":
429
+ executor = ToolExecutor()
430
+
431
+ # Test file read
432
+ print("Testing Read tool...")
433
+ result = executor.execute({
434
+ "tool": "Read",
435
+ "arguments": {"file_path": "~/.zshrc"}
436
+ })
437
+ print(f"Read result: {result.get('size', 'error')} bytes")
438
+
439
+ # Test bash
440
+ print("\nTesting Bash tool...")
441
+ result = executor.execute({
442
+ "tool": "Bash",
443
+ "arguments": {"command": "ls -la /tmp | head -5"}
444
+ })
445
+ print(f"Bash result: {result.get('stdout', result.get('error'))[:200]}")
446
+
447
+ # Test glob
448
+ print("\nTesting Glob tool...")
449
+ result = executor.execute({
450
+ "tool": "Glob",
451
+ "arguments": {"pattern": "*.py", "path": "."}
452
+ })
453
+ print(f"Glob result: {result.get('total', 'error')} matches")