smartify-ai 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.
Files changed (46) hide show
  1. smartify/__init__.py +3 -0
  2. smartify/agents/__init__.py +0 -0
  3. smartify/agents/adapters/__init__.py +13 -0
  4. smartify/agents/adapters/anthropic.py +253 -0
  5. smartify/agents/adapters/openai.py +289 -0
  6. smartify/api/__init__.py +26 -0
  7. smartify/api/auth.py +352 -0
  8. smartify/api/errors.py +380 -0
  9. smartify/api/events.py +345 -0
  10. smartify/api/server.py +992 -0
  11. smartify/cli/__init__.py +1 -0
  12. smartify/cli/main.py +430 -0
  13. smartify/engine/__init__.py +64 -0
  14. smartify/engine/approval.py +479 -0
  15. smartify/engine/orchestrator.py +1365 -0
  16. smartify/engine/scheduler.py +380 -0
  17. smartify/engine/spark.py +294 -0
  18. smartify/guardrails/__init__.py +22 -0
  19. smartify/guardrails/breakers.py +409 -0
  20. smartify/models/__init__.py +61 -0
  21. smartify/models/grid.py +625 -0
  22. smartify/notifications/__init__.py +22 -0
  23. smartify/notifications/webhook.py +556 -0
  24. smartify/state/__init__.py +46 -0
  25. smartify/state/checkpoint.py +558 -0
  26. smartify/state/resume.py +301 -0
  27. smartify/state/store.py +370 -0
  28. smartify/tools/__init__.py +17 -0
  29. smartify/tools/base.py +196 -0
  30. smartify/tools/builtin/__init__.py +79 -0
  31. smartify/tools/builtin/file.py +464 -0
  32. smartify/tools/builtin/http.py +195 -0
  33. smartify/tools/builtin/shell.py +137 -0
  34. smartify/tools/mcp/__init__.py +33 -0
  35. smartify/tools/mcp/adapter.py +157 -0
  36. smartify/tools/mcp/client.py +334 -0
  37. smartify/tools/mcp/registry.py +130 -0
  38. smartify/validator/__init__.py +0 -0
  39. smartify/validator/validate.py +271 -0
  40. smartify/workspace/__init__.py +5 -0
  41. smartify/workspace/manager.py +248 -0
  42. smartify_ai-0.1.0.dist-info/METADATA +201 -0
  43. smartify_ai-0.1.0.dist-info/RECORD +46 -0
  44. smartify_ai-0.1.0.dist-info/WHEEL +4 -0
  45. smartify_ai-0.1.0.dist-info/entry_points.txt +2 -0
  46. smartify_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,464 @@
1
+ """File operation tools."""
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from smartify.tools.base import Tool, ToolResult
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class FileReadTool(Tool):
14
+ """Read file contents."""
15
+
16
+ name = "file_read"
17
+ description = "Read the contents of a file. Returns the file content as text."
18
+
19
+ def __init__(
20
+ self,
21
+ base_path: Optional[str] = None,
22
+ max_size_bytes: int = 10 * 1024 * 1024, # 10MB
23
+ ):
24
+ """Initialize file read tool.
25
+
26
+ Args:
27
+ base_path: If set, all paths are relative to this base
28
+ max_size_bytes: Maximum file size to read
29
+ """
30
+ self.base_path = Path(base_path).resolve() if base_path else None
31
+ self.max_size_bytes = max_size_bytes
32
+
33
+ @property
34
+ def parameters(self) -> Dict[str, Any]:
35
+ return {
36
+ "type": "object",
37
+ "properties": {
38
+ "path": {
39
+ "type": "string",
40
+ "description": "Path to the file to read"
41
+ },
42
+ "encoding": {
43
+ "type": "string",
44
+ "description": "File encoding (default: utf-8)"
45
+ },
46
+ },
47
+ "required": ["path"]
48
+ }
49
+
50
+ def _resolve_path(self, path: str) -> Path:
51
+ """Resolve and validate path."""
52
+ p = Path(path)
53
+ if self.base_path:
54
+ if p.is_absolute():
55
+ return p.resolve()
56
+ else:
57
+ return (self.base_path / p).resolve()
58
+ return p.resolve()
59
+
60
+ def _check_path_escape(self, resolved: Path) -> bool:
61
+ """Check if resolved path escapes base_path. Returns True if escape detected."""
62
+ if not self.base_path:
63
+ return False
64
+ try:
65
+ resolved.relative_to(self.base_path)
66
+ return False
67
+ except ValueError:
68
+ return True
69
+
70
+ async def execute(
71
+ self,
72
+ path: str,
73
+ encoding: str = "utf-8",
74
+ **kwargs
75
+ ) -> ToolResult:
76
+ """Read a file.
77
+
78
+ Args:
79
+ path: File path
80
+ encoding: Text encoding
81
+
82
+ Returns:
83
+ ToolResult with file contents
84
+ """
85
+ try:
86
+ resolved = self._resolve_path(path)
87
+
88
+ # Security: check path doesn't escape base_path
89
+ if self._check_path_escape(resolved):
90
+ return ToolResult(
91
+ success=False,
92
+ error=f"Path escapes allowed directory: {path}"
93
+ )
94
+
95
+ if not resolved.exists():
96
+ return ToolResult(
97
+ success=False,
98
+ error=f"File not found: {path}"
99
+ )
100
+
101
+ if not resolved.is_file():
102
+ return ToolResult(
103
+ success=False,
104
+ error=f"Not a file: {path}"
105
+ )
106
+
107
+ # Check size
108
+ size = resolved.stat().st_size
109
+ if size > self.max_size_bytes:
110
+ return ToolResult(
111
+ success=False,
112
+ error=f"File too large: {size} bytes (max: {self.max_size_bytes})"
113
+ )
114
+
115
+ content = resolved.read_text(encoding=encoding)
116
+
117
+ return ToolResult(
118
+ success=True,
119
+ output=content,
120
+ metadata={
121
+ "path": str(resolved),
122
+ "size": size,
123
+ "encoding": encoding,
124
+ }
125
+ )
126
+
127
+ except UnicodeDecodeError as e:
128
+ return ToolResult(
129
+ success=False,
130
+ error=f"Failed to decode file with encoding {encoding}: {e}"
131
+ )
132
+ except Exception as e:
133
+ return ToolResult(
134
+ success=False,
135
+ error=f"Failed to read file: {str(e)}"
136
+ )
137
+
138
+
139
+ class FileWriteTool(Tool):
140
+ """Write content to a file."""
141
+
142
+ name = "file_write"
143
+ description = "Write content to a file. Creates the file if it doesn't exist, or overwrites if it does."
144
+
145
+ def __init__(
146
+ self,
147
+ base_path: Optional[str] = None,
148
+ create_dirs: bool = True,
149
+ ):
150
+ """Initialize file write tool.
151
+
152
+ Args:
153
+ base_path: If set, all paths are relative to this base
154
+ create_dirs: Create parent directories if they don't exist
155
+ """
156
+ self.base_path = Path(base_path).resolve() if base_path else None
157
+ self.create_dirs = create_dirs
158
+
159
+ @property
160
+ def parameters(self) -> Dict[str, Any]:
161
+ return {
162
+ "type": "object",
163
+ "properties": {
164
+ "path": {
165
+ "type": "string",
166
+ "description": "Path to the file to write"
167
+ },
168
+ "content": {
169
+ "type": "string",
170
+ "description": "Content to write to the file"
171
+ },
172
+ "append": {
173
+ "type": "boolean",
174
+ "description": "Append to file instead of overwriting (default: false)"
175
+ },
176
+ "encoding": {
177
+ "type": "string",
178
+ "description": "File encoding (default: utf-8)"
179
+ },
180
+ },
181
+ "required": ["path", "content"]
182
+ }
183
+
184
+ def _resolve_path(self, path: str) -> Path:
185
+ """Resolve and validate path."""
186
+ p = Path(path)
187
+ if self.base_path:
188
+ if p.is_absolute():
189
+ return p.resolve()
190
+ else:
191
+ return (self.base_path / p).resolve()
192
+ return p.resolve()
193
+
194
+ def _check_path_escape(self, resolved: Path) -> bool:
195
+ """Check if resolved path escapes base_path."""
196
+ if not self.base_path:
197
+ return False
198
+ try:
199
+ resolved.relative_to(self.base_path)
200
+ return False
201
+ except ValueError:
202
+ return True
203
+
204
+ async def execute(
205
+ self,
206
+ path: str,
207
+ content: str,
208
+ append: bool = False,
209
+ encoding: str = "utf-8",
210
+ **kwargs
211
+ ) -> ToolResult:
212
+ """Write to a file.
213
+
214
+ Args:
215
+ path: File path
216
+ content: Content to write
217
+ append: Append instead of overwrite
218
+ encoding: Text encoding
219
+
220
+ Returns:
221
+ ToolResult with write status
222
+ """
223
+ try:
224
+ resolved = self._resolve_path(path)
225
+
226
+ # Security: check path doesn't escape base_path
227
+ if self._check_path_escape(resolved):
228
+ return ToolResult(
229
+ success=False,
230
+ error=f"Path escapes allowed directory: {path}"
231
+ )
232
+
233
+ # Create parent directories
234
+ if self.create_dirs:
235
+ resolved.parent.mkdir(parents=True, exist_ok=True)
236
+
237
+ mode = "a" if append else "w"
238
+ with open(resolved, mode, encoding=encoding) as f:
239
+ f.write(content)
240
+
241
+ size = resolved.stat().st_size
242
+
243
+ return ToolResult(
244
+ success=True,
245
+ output={
246
+ "path": str(resolved),
247
+ "size": size,
248
+ "mode": "appended" if append else "written",
249
+ },
250
+ metadata={"path": str(resolved)}
251
+ )
252
+
253
+ except Exception as e:
254
+ return ToolResult(
255
+ success=False,
256
+ error=f"Failed to write file: {str(e)}"
257
+ )
258
+
259
+
260
+ class FileListTool(Tool):
261
+ """List files in a directory."""
262
+
263
+ name = "file_list"
264
+ description = "List files and directories in a path. Returns names and metadata."
265
+
266
+ def __init__(self, base_path: Optional[str] = None):
267
+ self.base_path = Path(base_path).resolve() if base_path else None
268
+
269
+ @property
270
+ def parameters(self) -> Dict[str, Any]:
271
+ return {
272
+ "type": "object",
273
+ "properties": {
274
+ "path": {
275
+ "type": "string",
276
+ "description": "Directory path to list (default: current directory)"
277
+ },
278
+ "pattern": {
279
+ "type": "string",
280
+ "description": "Glob pattern to filter files (e.g., '*.py')"
281
+ },
282
+ "recursive": {
283
+ "type": "boolean",
284
+ "description": "List recursively (default: false)"
285
+ },
286
+ },
287
+ "required": []
288
+ }
289
+
290
+ def _resolve_path(self, path: str) -> Path:
291
+ p = Path(path) if path else Path(".")
292
+ if self.base_path:
293
+ if p.is_absolute():
294
+ return p.resolve()
295
+ else:
296
+ return (self.base_path / p).resolve()
297
+ return p.resolve()
298
+
299
+ async def execute(
300
+ self,
301
+ path: str = ".",
302
+ pattern: Optional[str] = None,
303
+ recursive: bool = False,
304
+ **kwargs
305
+ ) -> ToolResult:
306
+ """List directory contents.
307
+
308
+ Args:
309
+ path: Directory path
310
+ pattern: Glob pattern
311
+ recursive: List recursively
312
+
313
+ Returns:
314
+ ToolResult with file listing
315
+ """
316
+ try:
317
+ resolved = self._resolve_path(path)
318
+
319
+ if not resolved.exists():
320
+ return ToolResult(
321
+ success=False,
322
+ error=f"Path not found: {path}"
323
+ )
324
+
325
+ if not resolved.is_dir():
326
+ return ToolResult(
327
+ success=False,
328
+ error=f"Not a directory: {path}"
329
+ )
330
+
331
+ # Get entries
332
+ if pattern:
333
+ if recursive:
334
+ entries = list(resolved.rglob(pattern))
335
+ else:
336
+ entries = list(resolved.glob(pattern))
337
+ else:
338
+ if recursive:
339
+ entries = list(resolved.rglob("*"))
340
+ else:
341
+ entries = list(resolved.iterdir())
342
+
343
+ # Build result
344
+ files = []
345
+ for entry in sorted(entries):
346
+ try:
347
+ stat = entry.stat()
348
+ files.append({
349
+ "name": entry.name,
350
+ "path": str(entry.relative_to(resolved) if not recursive else entry),
351
+ "type": "dir" if entry.is_dir() else "file",
352
+ "size": stat.st_size if entry.is_file() else None,
353
+ })
354
+ except (OSError, ValueError):
355
+ continue
356
+
357
+ return ToolResult(
358
+ success=True,
359
+ output={
360
+ "path": str(resolved),
361
+ "count": len(files),
362
+ "entries": files,
363
+ },
364
+ )
365
+
366
+ except Exception as e:
367
+ return ToolResult(
368
+ success=False,
369
+ error=f"Failed to list directory: {str(e)}"
370
+ )
371
+
372
+
373
+ class FileDeleteTool(Tool):
374
+ """Delete a file."""
375
+
376
+ name = "file_delete"
377
+ description = "Delete a file. Use with caution."
378
+
379
+ def __init__(
380
+ self,
381
+ base_path: Optional[str] = None,
382
+ allow_directories: bool = False,
383
+ ):
384
+ self.base_path = Path(base_path).resolve() if base_path else None
385
+ self.allow_directories = allow_directories
386
+
387
+ @property
388
+ def parameters(self) -> Dict[str, Any]:
389
+ return {
390
+ "type": "object",
391
+ "properties": {
392
+ "path": {
393
+ "type": "string",
394
+ "description": "Path to the file to delete"
395
+ },
396
+ },
397
+ "required": ["path"]
398
+ }
399
+
400
+ def _resolve_path(self, path: str) -> Path:
401
+ p = Path(path)
402
+ if self.base_path:
403
+ if p.is_absolute():
404
+ return p.resolve()
405
+ else:
406
+ return (self.base_path / p).resolve()
407
+ return p.resolve()
408
+
409
+ def _check_path_escape(self, resolved: Path) -> bool:
410
+ """Check if resolved path escapes base_path."""
411
+ if not self.base_path:
412
+ return False
413
+ try:
414
+ resolved.relative_to(self.base_path)
415
+ return False
416
+ except ValueError:
417
+ return True
418
+
419
+ async def execute(self, path: str, **kwargs) -> ToolResult:
420
+ """Delete a file.
421
+
422
+ Args:
423
+ path: File path to delete
424
+
425
+ Returns:
426
+ ToolResult with deletion status
427
+ """
428
+ try:
429
+ resolved = self._resolve_path(path)
430
+
431
+ # Security: check path doesn't escape base_path
432
+ if self._check_path_escape(resolved):
433
+ return ToolResult(
434
+ success=False,
435
+ error=f"Path escapes allowed directory: {path}"
436
+ )
437
+
438
+ if not resolved.exists():
439
+ return ToolResult(
440
+ success=False,
441
+ error=f"File not found: {path}"
442
+ )
443
+
444
+ if resolved.is_dir():
445
+ if not self.allow_directories:
446
+ return ToolResult(
447
+ success=False,
448
+ error="Directory deletion not allowed"
449
+ )
450
+ import shutil
451
+ shutil.rmtree(resolved)
452
+ else:
453
+ resolved.unlink()
454
+
455
+ return ToolResult(
456
+ success=True,
457
+ output={"deleted": str(resolved)},
458
+ )
459
+
460
+ except Exception as e:
461
+ return ToolResult(
462
+ success=False,
463
+ error=f"Failed to delete: {str(e)}"
464
+ )
@@ -0,0 +1,195 @@
1
+ """HTTP request tool."""
2
+
3
+ import logging
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import httpx
7
+
8
+ from smartify.tools.base import Tool, ToolResult
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class HttpTool(Tool):
14
+ """Make HTTP requests.
15
+
16
+ Supports GET, POST, PUT, PATCH, DELETE with JSON or form data.
17
+ """
18
+
19
+ name = "http"
20
+ description = "Make an HTTP request to a URL. Supports GET, POST, PUT, PATCH, DELETE methods with JSON or form data."
21
+
22
+ def __init__(
23
+ self,
24
+ timeout: float = 30.0,
25
+ max_response_size: int = 5 * 1024 * 1024, # 5MB
26
+ allowed_hosts: Optional[List[str]] = None,
27
+ blocked_hosts: Optional[List[str]] = None,
28
+ ):
29
+ """Initialize HTTP tool.
30
+
31
+ Args:
32
+ timeout: Request timeout in seconds
33
+ max_response_size: Maximum response size to read
34
+ allowed_hosts: If set, only these hosts are allowed
35
+ blocked_hosts: Hosts to block (e.g., internal IPs)
36
+ """
37
+ self.timeout = timeout
38
+ self.max_response_size = max_response_size
39
+ self.allowed_hosts = allowed_hosts
40
+ self.blocked_hosts = blocked_hosts or [
41
+ "localhost",
42
+ "127.0.0.1",
43
+ "0.0.0.0",
44
+ "169.254.", # Link-local
45
+ "10.", # Private
46
+ "172.16.", # Private
47
+ "192.168.", # Private
48
+ ]
49
+
50
+ @property
51
+ def parameters(self) -> Dict[str, Any]:
52
+ return {
53
+ "type": "object",
54
+ "properties": {
55
+ "url": {
56
+ "type": "string",
57
+ "description": "The URL to request"
58
+ },
59
+ "method": {
60
+ "type": "string",
61
+ "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
62
+ "description": "HTTP method (default: GET)"
63
+ },
64
+ "headers": {
65
+ "type": "object",
66
+ "description": "HTTP headers as key-value pairs"
67
+ },
68
+ "json": {
69
+ "type": "object",
70
+ "description": "JSON body for POST/PUT/PATCH"
71
+ },
72
+ "data": {
73
+ "type": "object",
74
+ "description": "Form data for POST/PUT/PATCH"
75
+ },
76
+ "params": {
77
+ "type": "object",
78
+ "description": "URL query parameters"
79
+ },
80
+ },
81
+ "required": ["url"]
82
+ }
83
+
84
+ def _check_host(self, url: str) -> Optional[str]:
85
+ """Check if host is allowed. Returns error message if blocked."""
86
+ try:
87
+ from urllib.parse import urlparse
88
+ parsed = urlparse(url)
89
+ host = parsed.hostname or ""
90
+
91
+ # Check blocked hosts
92
+ for blocked in self.blocked_hosts:
93
+ if host.startswith(blocked) or host == blocked:
94
+ return f"Host blocked: {host}"
95
+
96
+ # Check allowed hosts
97
+ if self.allowed_hosts:
98
+ if host not in self.allowed_hosts:
99
+ return f"Host not in allowlist: {host}"
100
+
101
+ return None
102
+ except Exception as e:
103
+ return f"Invalid URL: {e}"
104
+
105
+ async def execute(
106
+ self,
107
+ url: str,
108
+ method: str = "GET",
109
+ headers: Optional[Dict[str, str]] = None,
110
+ json: Optional[Dict[str, Any]] = None,
111
+ data: Optional[Dict[str, Any]] = None,
112
+ params: Optional[Dict[str, str]] = None,
113
+ **kwargs
114
+ ) -> ToolResult:
115
+ """Make an HTTP request.
116
+
117
+ Args:
118
+ url: Request URL
119
+ method: HTTP method
120
+ headers: Request headers
121
+ json: JSON body
122
+ data: Form data
123
+ params: Query parameters
124
+
125
+ Returns:
126
+ ToolResult with response data
127
+ """
128
+ # Security check
129
+ host_error = self._check_host(url)
130
+ if host_error:
131
+ return ToolResult(
132
+ success=False,
133
+ error=host_error
134
+ )
135
+
136
+ method = method.upper()
137
+
138
+ try:
139
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
140
+ response = await client.request(
141
+ method=method,
142
+ url=url,
143
+ headers=headers,
144
+ json=json,
145
+ data=data,
146
+ params=params,
147
+ )
148
+
149
+ # Check response size
150
+ content_length = response.headers.get("content-length")
151
+ if content_length and int(content_length) > self.max_response_size:
152
+ return ToolResult(
153
+ success=False,
154
+ error=f"Response too large: {content_length} bytes"
155
+ )
156
+
157
+ # Read response
158
+ content = response.text
159
+
160
+ # Try to parse JSON
161
+ try:
162
+ json_content = response.json()
163
+ except Exception:
164
+ json_content = None
165
+
166
+ return ToolResult(
167
+ success=200 <= response.status_code < 300,
168
+ output={
169
+ "status_code": response.status_code,
170
+ "headers": dict(response.headers),
171
+ "body": json_content if json_content else content,
172
+ "is_json": json_content is not None,
173
+ },
174
+ error=f"HTTP {response.status_code}" if response.status_code >= 400 else None,
175
+ metadata={
176
+ "url": str(response.url),
177
+ "method": method,
178
+ }
179
+ )
180
+
181
+ except httpx.TimeoutException:
182
+ return ToolResult(
183
+ success=False,
184
+ error=f"Request timed out after {self.timeout}s"
185
+ )
186
+ except httpx.RequestError as e:
187
+ return ToolResult(
188
+ success=False,
189
+ error=f"Request failed: {str(e)}"
190
+ )
191
+ except Exception as e:
192
+ return ToolResult(
193
+ success=False,
194
+ error=f"HTTP request error: {str(e)}"
195
+ )