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,137 @@
1
+ """Shell command execution tool."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import shlex
6
+ from typing import Any, Dict, Optional
7
+
8
+ from smartify.tools.base import Tool, ToolResult
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ShellTool(Tool):
14
+ """Execute shell commands.
15
+
16
+ Runs commands in a subprocess with configurable timeout and working directory.
17
+ """
18
+
19
+ name = "shell"
20
+ description = "Execute a shell command and return its output. Use for running CLI tools, scripts, or system commands."
21
+
22
+ def __init__(
23
+ self,
24
+ default_timeout: float = 60.0,
25
+ allowed_commands: Optional[list[str]] = None,
26
+ blocked_commands: Optional[list[str]] = None,
27
+ working_dir: Optional[str] = None,
28
+ ):
29
+ """Initialize shell tool.
30
+
31
+ Args:
32
+ default_timeout: Default command timeout in seconds
33
+ allowed_commands: If set, only these command prefixes are allowed
34
+ blocked_commands: Commands to block (e.g., ["rm -rf", "sudo"])
35
+ working_dir: Default working directory
36
+ """
37
+ self.default_timeout = default_timeout
38
+ self.allowed_commands = allowed_commands
39
+ self.blocked_commands = blocked_commands or ["rm -rf /", "sudo rm", ":(){ :|:& };:"]
40
+ self.working_dir = working_dir
41
+
42
+ @property
43
+ def parameters(self) -> Dict[str, Any]:
44
+ return {
45
+ "type": "object",
46
+ "properties": {
47
+ "command": {
48
+ "type": "string",
49
+ "description": "The shell command to execute"
50
+ },
51
+ "timeout": {
52
+ "type": "number",
53
+ "description": f"Timeout in seconds (default: {self.default_timeout})"
54
+ },
55
+ "working_dir": {
56
+ "type": "string",
57
+ "description": "Working directory for the command"
58
+ },
59
+ },
60
+ "required": ["command"]
61
+ }
62
+
63
+ async def execute(
64
+ self,
65
+ command: str,
66
+ timeout: Optional[float] = None,
67
+ working_dir: Optional[str] = None,
68
+ **kwargs
69
+ ) -> ToolResult:
70
+ """Execute a shell command.
71
+
72
+ Args:
73
+ command: Shell command to run
74
+ timeout: Command timeout in seconds
75
+ working_dir: Working directory
76
+
77
+ Returns:
78
+ ToolResult with stdout/stderr
79
+ """
80
+ # Security checks
81
+ for blocked in self.blocked_commands:
82
+ if blocked in command:
83
+ return ToolResult(
84
+ success=False,
85
+ error=f"Blocked command pattern: {blocked}"
86
+ )
87
+
88
+ if self.allowed_commands:
89
+ cmd_prefix = command.split()[0] if command.split() else ""
90
+ if cmd_prefix not in self.allowed_commands:
91
+ return ToolResult(
92
+ success=False,
93
+ error=f"Command not in allowlist: {cmd_prefix}"
94
+ )
95
+
96
+ timeout = timeout or self.default_timeout
97
+ cwd = working_dir or self.working_dir
98
+
99
+ try:
100
+ process = await asyncio.create_subprocess_shell(
101
+ command,
102
+ stdout=asyncio.subprocess.PIPE,
103
+ stderr=asyncio.subprocess.PIPE,
104
+ cwd=cwd,
105
+ )
106
+
107
+ stdout, stderr = await asyncio.wait_for(
108
+ process.communicate(),
109
+ timeout=timeout
110
+ )
111
+
112
+ stdout_text = stdout.decode("utf-8", errors="replace")
113
+ stderr_text = stderr.decode("utf-8", errors="replace")
114
+
115
+ return ToolResult(
116
+ success=process.returncode == 0,
117
+ output={
118
+ "stdout": stdout_text,
119
+ "stderr": stderr_text,
120
+ "exit_code": process.returncode,
121
+ },
122
+ error=stderr_text if process.returncode != 0 else None,
123
+ metadata={"command": command, "cwd": cwd}
124
+ )
125
+
126
+ except asyncio.TimeoutError:
127
+ return ToolResult(
128
+ success=False,
129
+ error=f"Command timed out after {timeout}s",
130
+ metadata={"command": command}
131
+ )
132
+ except Exception as e:
133
+ return ToolResult(
134
+ success=False,
135
+ error=f"Command execution failed: {str(e)}",
136
+ metadata={"command": command}
137
+ )
@@ -0,0 +1,33 @@
1
+ """MCP (Model Context Protocol) integration for Smartify.
2
+
3
+ This module provides plug-and-play integration with external MCP servers,
4
+ allowing grids to use tools from any MCP-compatible server.
5
+
6
+ Install the optional dependency:
7
+ pip install smartify[mcp]
8
+
9
+ Example usage in a grid:
10
+ tools:
11
+ mcpServers:
12
+ - id: filesystem
13
+ transport: stdio
14
+ command: npx
15
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
16
+ prefix: fs
17
+ """
18
+
19
+ from smartify.tools.mcp.client import (
20
+ McpClient,
21
+ McpServerConfig,
22
+ McpTransport,
23
+ )
24
+ from smartify.tools.mcp.adapter import McpToolWrapper
25
+ from smartify.tools.mcp.registry import register_mcp_server
26
+
27
+ __all__ = [
28
+ "McpClient",
29
+ "McpServerConfig",
30
+ "McpTransport",
31
+ "McpToolWrapper",
32
+ "register_mcp_server",
33
+ ]
@@ -0,0 +1,157 @@
1
+ """MCP tool adapter for Smartify.
2
+
3
+ Wraps MCP tools to implement the Smartify Tool interface, allowing
4
+ MCP server tools to be used seamlessly in grid execution.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any, Dict, Optional, TYPE_CHECKING
11
+
12
+ from smartify.tools.base import Tool, ToolResult
13
+
14
+ if TYPE_CHECKING:
15
+ from smartify.tools.mcp.client import McpClient, McpToolDef
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class McpToolWrapper(Tool):
21
+ """Wraps an MCP tool to implement the Smartify Tool interface.
22
+
23
+ This adapter allows MCP server tools to be registered in the Smartify
24
+ ToolRegistry and used by grid nodes just like builtin tools.
25
+
26
+ Example:
27
+ client = McpClient(config)
28
+ await client.connect()
29
+
30
+ tools = await client.list_tools()
31
+ for tool_def in tools:
32
+ wrapper = McpToolWrapper(client, tool_def, prefix="mcp")
33
+ registry.register(wrapper)
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ client: "McpClient",
39
+ tool_def: "McpToolDef",
40
+ prefix: Optional[str] = None,
41
+ ):
42
+ """Initialize the MCP tool wrapper.
43
+
44
+ Args:
45
+ client: Connected MCP client instance
46
+ tool_def: Tool definition from MCP server
47
+ prefix: Optional prefix for the tool name (e.g. "mcp" -> "mcp_read_file")
48
+ """
49
+ self._client = client
50
+ self._tool_def = tool_def
51
+ self._prefix = prefix
52
+
53
+ # Build the Smartify tool name
54
+ if prefix:
55
+ self._name = f"{prefix}_{tool_def.name}"
56
+ else:
57
+ self._name = tool_def.name
58
+
59
+ # Store the original MCP tool name for calling
60
+ self._mcp_tool_name = tool_def.name
61
+
62
+ @property
63
+ def name(self) -> str:
64
+ """Tool name (possibly prefixed)."""
65
+ return self._name
66
+
67
+ @property
68
+ def description(self) -> str:
69
+ """Tool description from MCP server."""
70
+ return self._tool_def.description
71
+
72
+ @property
73
+ def parameters(self) -> Dict[str, Any]:
74
+ """JSON Schema for tool parameters from MCP server."""
75
+ return self._tool_def.parameters
76
+
77
+ @property
78
+ def mcp_server_id(self) -> str:
79
+ """ID of the MCP server this tool belongs to."""
80
+ return self._client.config.id
81
+
82
+ @property
83
+ def mcp_tool_name(self) -> str:
84
+ """Original tool name on the MCP server."""
85
+ return self._mcp_tool_name
86
+
87
+ async def execute(self, **kwargs) -> ToolResult:
88
+ """Execute the MCP tool.
89
+
90
+ Args:
91
+ **kwargs: Tool arguments matching the parameter schema
92
+
93
+ Returns:
94
+ ToolResult with success status and output/error
95
+ """
96
+ if not self._client.is_connected:
97
+ return ToolResult(
98
+ success=False,
99
+ error=f"MCP server '{self._client.config.id}' is not connected",
100
+ )
101
+
102
+ logger.debug(
103
+ f"Executing MCP tool '{self._mcp_tool_name}' on server "
104
+ f"'{self._client.config.id}' with args: {kwargs}"
105
+ )
106
+
107
+ try:
108
+ result = await self._client.call_tool(self._mcp_tool_name, kwargs)
109
+
110
+ return ToolResult(
111
+ success=result["success"],
112
+ output=result["output"],
113
+ error=result.get("error"),
114
+ metadata={
115
+ "mcp_server": self._client.config.id,
116
+ "mcp_tool": self._mcp_tool_name,
117
+ },
118
+ )
119
+
120
+ except Exception as e:
121
+ logger.error(
122
+ f"MCP tool execution failed: {self._mcp_tool_name} - {e}"
123
+ )
124
+ return ToolResult(
125
+ success=False,
126
+ error=f"MCP tool execution failed: {e}",
127
+ metadata={
128
+ "mcp_server": self._client.config.id,
129
+ "mcp_tool": self._mcp_tool_name,
130
+ },
131
+ )
132
+
133
+ def to_openai_format(self) -> Dict[str, Any]:
134
+ """Convert to OpenAI function calling format."""
135
+ return {
136
+ "type": "function",
137
+ "function": {
138
+ "name": self.name,
139
+ "description": self.description,
140
+ "parameters": self.parameters,
141
+ },
142
+ }
143
+
144
+ def to_anthropic_format(self) -> Dict[str, Any]:
145
+ """Convert to Anthropic tool format."""
146
+ return {
147
+ "name": self.name,
148
+ "description": self.description,
149
+ "input_schema": self.parameters,
150
+ }
151
+
152
+ def __repr__(self) -> str:
153
+ return (
154
+ f"McpToolWrapper(name={self.name!r}, "
155
+ f"mcp_server={self._client.config.id!r}, "
156
+ f"mcp_tool={self._mcp_tool_name!r})"
157
+ )
@@ -0,0 +1,334 @@
1
+ """MCP client wrapper for Smartify.
2
+
3
+ Provides a thin wrapper around the MCP Python SDK for connecting to
4
+ MCP servers and calling their tools.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from contextlib import AsyncExitStack
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Check for MCP SDK availability
18
+ try:
19
+ from mcp import ClientSession, StdioServerParameters
20
+ from mcp.client.stdio import stdio_client
21
+ from mcp.client.sse import sse_client
22
+ from mcp.client.streamable_http import streamable_http_client
23
+ import mcp.types as mcp_types
24
+
25
+ MCP_AVAILABLE = True
26
+ except ImportError:
27
+ MCP_AVAILABLE = False
28
+ ClientSession = None
29
+ StdioServerParameters = None
30
+ stdio_client = None
31
+ sse_client = None
32
+ streamable_http_client = None
33
+ mcp_types = None
34
+
35
+
36
+ class McpTransport(str, Enum):
37
+ """Transport type for connecting to MCP servers."""
38
+
39
+ STDIO = "stdio"
40
+ SSE = "sse"
41
+ STREAMABLE_HTTP = "streamable_http"
42
+
43
+
44
+ @dataclass
45
+ class McpToolDef:
46
+ """Definition of a tool from an MCP server."""
47
+
48
+ name: str
49
+ description: str
50
+ parameters: Dict[str, Any] # JSON Schema
51
+ output_schema: Optional[Dict[str, Any]] = None
52
+
53
+
54
+ @dataclass
55
+ class McpServerConfig:
56
+ """Configuration for connecting to an MCP server."""
57
+
58
+ id: str
59
+ transport: McpTransport = McpTransport.STDIO
60
+
61
+ # For stdio transport
62
+ command: Optional[str] = None
63
+ args: List[str] = field(default_factory=list)
64
+ env: Optional[Dict[str, str]] = None
65
+ cwd: Optional[str] = None
66
+
67
+ # For SSE/HTTP transport
68
+ url: Optional[str] = None
69
+ headers: Optional[Dict[str, str]] = None
70
+
71
+ # Tool naming
72
+ prefix: Optional[str] = None # Prefix for tool names (e.g. "mcp_" -> "mcp_read_file")
73
+
74
+ # Optional: only expose specific tools
75
+ tools: Optional[List[str]] = None # If set, only these tools are registered
76
+
77
+
78
+ def _check_mcp_available() -> None:
79
+ """Raise ImportError if MCP SDK is not installed."""
80
+ if not MCP_AVAILABLE:
81
+ raise ImportError(
82
+ "MCP SDK not installed. Install with: pip install smartify[mcp]"
83
+ )
84
+
85
+
86
+ class McpClient:
87
+ """Client for connecting to and interacting with an MCP server.
88
+
89
+ Usage:
90
+ config = McpServerConfig(
91
+ id="filesystem",
92
+ transport=McpTransport.STDIO,
93
+ command="npx",
94
+ args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
95
+ )
96
+
97
+ async with McpClient(config) as client:
98
+ tools = await client.list_tools()
99
+ result = await client.call_tool("read_file", {"path": "/tmp/test.txt"})
100
+ """
101
+
102
+ def __init__(self, config: McpServerConfig):
103
+ _check_mcp_available()
104
+ self.config = config
105
+ self._exit_stack: Optional[AsyncExitStack] = None
106
+ self._session: Optional[ClientSession] = None
107
+ self._connected = False
108
+
109
+ async def __aenter__(self) -> "McpClient":
110
+ await self.connect()
111
+ return self
112
+
113
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
114
+ await self.disconnect()
115
+
116
+ async def connect(self) -> None:
117
+ """Connect to the MCP server."""
118
+ if self._connected:
119
+ return
120
+
121
+ _check_mcp_available()
122
+
123
+ self._exit_stack = AsyncExitStack()
124
+
125
+ try:
126
+ if self.config.transport == McpTransport.STDIO:
127
+ await self._connect_stdio()
128
+ elif self.config.transport == McpTransport.SSE:
129
+ await self._connect_sse()
130
+ elif self.config.transport == McpTransport.STREAMABLE_HTTP:
131
+ await self._connect_streamable_http()
132
+ else:
133
+ raise ValueError(f"Unknown transport: {self.config.transport}")
134
+
135
+ # Initialize the session
136
+ await self._session.initialize()
137
+ self._connected = True
138
+
139
+ logger.info(
140
+ f"Connected to MCP server '{self.config.id}' via {self.config.transport.value}"
141
+ )
142
+
143
+ except Exception as e:
144
+ # Clean up on connection failure
145
+ if self._exit_stack:
146
+ await self._exit_stack.aclose()
147
+ self._exit_stack = None
148
+ raise ConnectionError(
149
+ f"Failed to connect to MCP server '{self.config.id}': {e}"
150
+ ) from e
151
+
152
+ async def _connect_stdio(self) -> None:
153
+ """Connect via stdio transport."""
154
+ if not self.config.command:
155
+ raise ValueError("stdio transport requires 'command' in config")
156
+
157
+ server_params = StdioServerParameters(
158
+ command=self.config.command,
159
+ args=self.config.args,
160
+ env=self.config.env,
161
+ cwd=self.config.cwd,
162
+ )
163
+
164
+ transport = await self._exit_stack.enter_async_context(
165
+ stdio_client(server_params)
166
+ )
167
+ read_stream, write_stream = transport
168
+
169
+ self._session = await self._exit_stack.enter_async_context(
170
+ ClientSession(read_stream, write_stream)
171
+ )
172
+
173
+ async def _connect_sse(self) -> None:
174
+ """Connect via SSE transport."""
175
+ if not self.config.url:
176
+ raise ValueError("SSE transport requires 'url' in config")
177
+
178
+ transport = await self._exit_stack.enter_async_context(
179
+ sse_client(url=self.config.url, headers=self.config.headers)
180
+ )
181
+ read_stream, write_stream = transport
182
+
183
+ self._session = await self._exit_stack.enter_async_context(
184
+ ClientSession(read_stream, write_stream)
185
+ )
186
+
187
+ async def _connect_streamable_http(self) -> None:
188
+ """Connect via streamable HTTP transport."""
189
+ if not self.config.url:
190
+ raise ValueError("streamable_http transport requires 'url' in config")
191
+
192
+ transport = await self._exit_stack.enter_async_context(
193
+ streamable_http_client(url=self.config.url)
194
+ )
195
+ read_stream, write_stream, _ = transport
196
+
197
+ self._session = await self._exit_stack.enter_async_context(
198
+ ClientSession(read_stream, write_stream)
199
+ )
200
+
201
+ async def disconnect(self) -> None:
202
+ """Disconnect from the MCP server."""
203
+ if self._exit_stack:
204
+ await self._exit_stack.aclose()
205
+ self._exit_stack = None
206
+ self._session = None
207
+ self._connected = False
208
+ logger.debug(f"Disconnected from MCP server '{self.config.id}'")
209
+
210
+ @property
211
+ def is_connected(self) -> bool:
212
+ """Check if connected to the server."""
213
+ return self._connected and self._session is not None
214
+
215
+ async def list_tools(self) -> List[McpToolDef]:
216
+ """List available tools from the MCP server.
217
+
218
+ Returns:
219
+ List of tool definitions with name, description, and parameter schema.
220
+ """
221
+ if not self.is_connected:
222
+ raise RuntimeError("Not connected to MCP server")
223
+
224
+ result = await self._session.list_tools()
225
+
226
+ tools = []
227
+ for tool in result.tools:
228
+ # Filter if specific tools are requested
229
+ if self.config.tools and tool.name not in self.config.tools:
230
+ continue
231
+
232
+ tools.append(
233
+ McpToolDef(
234
+ name=tool.name,
235
+ description=tool.description or "",
236
+ parameters=tool.inputSchema or {"type": "object", "properties": {}},
237
+ output_schema=tool.outputSchema,
238
+ )
239
+ )
240
+
241
+ logger.debug(
242
+ f"Listed {len(tools)} tools from MCP server '{self.config.id}': "
243
+ f"{[t.name for t in tools]}"
244
+ )
245
+ return tools
246
+
247
+ async def call_tool(
248
+ self, name: str, arguments: Optional[Dict[str, Any]] = None
249
+ ) -> Dict[str, Any]:
250
+ """Call a tool on the MCP server.
251
+
252
+ Args:
253
+ name: Tool name (without prefix)
254
+ arguments: Tool arguments
255
+
256
+ Returns:
257
+ Dict with 'success', 'output', and optionally 'error' keys.
258
+ """
259
+ if not self.is_connected:
260
+ raise RuntimeError("Not connected to MCP server")
261
+
262
+ try:
263
+ result = await self._session.call_tool(name, arguments or {})
264
+
265
+ # Process the result
266
+ if result.isError:
267
+ # Extract error message from content
268
+ error_msg = self._extract_text_content(result.content)
269
+ return {
270
+ "success": False,
271
+ "output": None,
272
+ "error": error_msg or "Tool execution failed",
273
+ }
274
+
275
+ # Extract output from content
276
+ output = self._extract_content(result)
277
+ return {
278
+ "success": True,
279
+ "output": output,
280
+ "error": None,
281
+ }
282
+
283
+ except Exception as e:
284
+ logger.error(f"MCP tool call failed: {name} - {e}")
285
+ return {
286
+ "success": False,
287
+ "output": None,
288
+ "error": str(e),
289
+ }
290
+
291
+ def _extract_text_content(self, content: List) -> Optional[str]:
292
+ """Extract text from MCP content list."""
293
+ if not content:
294
+ return None
295
+
296
+ texts = []
297
+ for item in content:
298
+ if hasattr(item, "text"):
299
+ texts.append(item.text)
300
+ elif hasattr(item, "type") and item.type == "text":
301
+ texts.append(getattr(item, "text", str(item)))
302
+
303
+ return "\n".join(texts) if texts else None
304
+
305
+ def _extract_content(self, result) -> Any:
306
+ """Extract output from MCP CallToolResult."""
307
+ # If there's structured content, prefer that
308
+ if hasattr(result, "structuredContent") and result.structuredContent:
309
+ return result.structuredContent
310
+
311
+ # Otherwise extract from content list
312
+ if not result.content:
313
+ return None
314
+
315
+ # Single text item -> return as string
316
+ if len(result.content) == 1:
317
+ item = result.content[0]
318
+ if hasattr(item, "text"):
319
+ return item.text
320
+ elif hasattr(item, "data"):
321
+ # Binary/image content
322
+ return {"type": getattr(item, "type", "blob"), "data": item.data}
323
+
324
+ # Multiple items -> return as list
325
+ outputs = []
326
+ for item in result.content:
327
+ if hasattr(item, "text"):
328
+ outputs.append(item.text)
329
+ elif hasattr(item, "data"):
330
+ outputs.append({"type": getattr(item, "type", "blob"), "data": item.data})
331
+ else:
332
+ outputs.append(str(item))
333
+
334
+ return outputs