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.
- package/LICENSE +21 -0
- package/QUICK_START.md +255 -0
- package/README.md +453 -0
- package/bin/install-launchagent.js +135 -0
- package/bin/mageagent.js +255 -0
- package/bin/postinstall.js +43 -0
- package/config/com.adverant.mageagent.plist +38 -0
- package/config/config.example.json +41 -0
- package/docs/AUTOSTART.md +300 -0
- package/docs/MENUBAR_APP.md +238 -0
- package/docs/PATTERNS.md +501 -0
- package/docs/TROUBLESHOOTING.md +364 -0
- package/docs/VSCODE_SETUP.md +230 -0
- package/docs/assets/mageagent-logo.png +0 -0
- package/docs/assets/mageagent-logo.svg +57 -0
- package/docs/assets/menubar-screenshot.png +0 -0
- package/docs/diagrams/architecture.md +229 -0
- package/mageagent/__init__.py +4 -0
- package/mageagent/server.py +951 -0
- package/mageagent/tool_executor.py +453 -0
- package/menubar-app/MageAgentMenuBar/AppDelegate.swift +1337 -0
- package/menubar-app/MageAgentMenuBar/Info.plist +38 -0
- package/menubar-app/MageAgentMenuBar/main.swift +16 -0
- package/menubar-app/Package.swift +18 -0
- package/menubar-app/build/MageAgentMenuBar.app/Contents/Info.plist +38 -0
- package/menubar-app/build/MageAgentMenuBar.app/Contents/MacOS/MageAgentMenuBar +0 -0
- package/menubar-app/build/MageAgentMenuBar.app/Contents/PkgInfo +1 -0
- package/menubar-app/build/MageAgentMenuBar.app/Contents/Resources/icon.png +0 -0
- package/menubar-app/build.sh +96 -0
- package/package.json +81 -0
- package/scripts/build-dmg.sh +196 -0
- package/scripts/install.sh +641 -0
- package/scripts/mageagent-server.sh +218 -0
|
@@ -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")
|