sandboxy 0.0.1__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 (60) hide show
  1. sandboxy/__init__.py +3 -0
  2. sandboxy/agents/__init__.py +21 -0
  3. sandboxy/agents/base.py +66 -0
  4. sandboxy/agents/llm_prompt.py +308 -0
  5. sandboxy/agents/loader.py +222 -0
  6. sandboxy/api/__init__.py +5 -0
  7. sandboxy/api/app.py +76 -0
  8. sandboxy/api/routes/__init__.py +1 -0
  9. sandboxy/api/routes/agents.py +92 -0
  10. sandboxy/api/routes/local.py +1388 -0
  11. sandboxy/api/routes/tools.py +106 -0
  12. sandboxy/cli/__init__.py +1 -0
  13. sandboxy/cli/main.py +1196 -0
  14. sandboxy/cli/type_detector.py +48 -0
  15. sandboxy/config.py +49 -0
  16. sandboxy/core/__init__.py +1 -0
  17. sandboxy/core/async_runner.py +824 -0
  18. sandboxy/core/mdl_parser.py +441 -0
  19. sandboxy/core/runner.py +599 -0
  20. sandboxy/core/safe_eval.py +165 -0
  21. sandboxy/core/state.py +234 -0
  22. sandboxy/datasets/__init__.py +20 -0
  23. sandboxy/datasets/loader.py +193 -0
  24. sandboxy/datasets/runner.py +442 -0
  25. sandboxy/errors.py +166 -0
  26. sandboxy/local/context.py +235 -0
  27. sandboxy/local/results.py +173 -0
  28. sandboxy/logging.py +31 -0
  29. sandboxy/mcp/__init__.py +25 -0
  30. sandboxy/mcp/client.py +360 -0
  31. sandboxy/mcp/wrapper.py +99 -0
  32. sandboxy/providers/__init__.py +34 -0
  33. sandboxy/providers/anthropic_provider.py +271 -0
  34. sandboxy/providers/base.py +123 -0
  35. sandboxy/providers/http_client.py +101 -0
  36. sandboxy/providers/openai_provider.py +282 -0
  37. sandboxy/providers/openrouter.py +958 -0
  38. sandboxy/providers/registry.py +199 -0
  39. sandboxy/scenarios/__init__.py +11 -0
  40. sandboxy/scenarios/comparison.py +491 -0
  41. sandboxy/scenarios/loader.py +262 -0
  42. sandboxy/scenarios/runner.py +468 -0
  43. sandboxy/scenarios/unified.py +1434 -0
  44. sandboxy/session/__init__.py +21 -0
  45. sandboxy/session/manager.py +278 -0
  46. sandboxy/tools/__init__.py +34 -0
  47. sandboxy/tools/base.py +127 -0
  48. sandboxy/tools/loader.py +270 -0
  49. sandboxy/tools/yaml_tools.py +708 -0
  50. sandboxy/ui/__init__.py +27 -0
  51. sandboxy/ui/dist/assets/index-CgAkYWrJ.css +1 -0
  52. sandboxy/ui/dist/assets/index-D4zoGFcr.js +347 -0
  53. sandboxy/ui/dist/index.html +14 -0
  54. sandboxy/utils/__init__.py +3 -0
  55. sandboxy/utils/time.py +20 -0
  56. sandboxy-0.0.1.dist-info/METADATA +241 -0
  57. sandboxy-0.0.1.dist-info/RECORD +60 -0
  58. sandboxy-0.0.1.dist-info/WHEEL +4 -0
  59. sandboxy-0.0.1.dist-info/entry_points.txt +3 -0
  60. sandboxy-0.0.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,235 @@
1
+ """Local development context manager.
2
+
3
+ This module provides the LocalContext class for managing local development
4
+ configurations in Sandboxy. It handles path resolution, file discovery,
5
+ and environment variable overrides for containerized deployments.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import logging
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import yaml
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Global context - set when running in local mode
23
+ _local_context: LocalContext | None = None
24
+
25
+
26
+ @dataclass
27
+ class LocalContext:
28
+ """Configuration for local development mode.
29
+
30
+ Manages paths and discovery for a local Sandboxy project.
31
+ """
32
+
33
+ root_dir: Path
34
+ scenarios_dir: Path = field(init=False)
35
+ tools_dir: Path = field(init=False)
36
+ agents_dir: Path = field(init=False)
37
+ runs_dir: Path = field(init=False)
38
+ datasets_dir: Path = field(init=False)
39
+
40
+ def __post_init__(self) -> None:
41
+ """Initialize derived paths from root directory."""
42
+ # Support env var overrides for containerization
43
+ self.scenarios_dir = Path(
44
+ os.environ.get("SANDBOXY_SCENARIOS_DIR", self.root_dir / "scenarios")
45
+ )
46
+ self.tools_dir = Path(os.environ.get("SANDBOXY_TOOLS_DIR", self.root_dir / "tools"))
47
+ self.agents_dir = Path(os.environ.get("SANDBOXY_AGENTS_DIR", self.root_dir / "agents"))
48
+ self.runs_dir = Path(os.environ.get("SANDBOXY_RUNS_DIR", self.root_dir / "runs"))
49
+ self.datasets_dir = Path(
50
+ os.environ.get("SANDBOXY_DATASETS_DIR", self.root_dir / "datasets")
51
+ )
52
+
53
+ def discover(self) -> dict[str, list[dict[str, Any]]]:
54
+ """Discover all local files (YAML and Python tools).
55
+
56
+ Returns:
57
+ Dictionary with scenarios, tools, and agents lists.
58
+
59
+ """
60
+ # Tools include both YAML and Python files
61
+ tools = self._list_yaml_files(self.tools_dir)
62
+ tools.extend(self._list_python_tools(self.tools_dir))
63
+
64
+ return {
65
+ "scenarios": self._list_yaml_files(self.scenarios_dir),
66
+ "tools": tools,
67
+ "agents": self._list_yaml_files(self.agents_dir),
68
+ }
69
+
70
+ def _list_yaml_files(self, directory: Path) -> list[dict[str, Any]]:
71
+ """List YAML files with basic metadata.
72
+
73
+ Args:
74
+ directory: Directory to scan for YAML files.
75
+
76
+ Returns:
77
+ List of file metadata dictionaries.
78
+
79
+ """
80
+ files = []
81
+ if not directory.exists():
82
+ return files
83
+
84
+ for path in directory.glob("**/*.yaml"):
85
+ files.append(self._parse_yaml_metadata(path))
86
+ for path in directory.glob("**/*.yml"):
87
+ files.append(self._parse_yaml_metadata(path))
88
+
89
+ # Sort by name
90
+ files.sort(key=lambda x: x.get("name", x.get("id", "")))
91
+ return files
92
+
93
+ def _parse_yaml_metadata(self, path: Path) -> dict[str, Any]:
94
+ """Parse basic metadata from a YAML file.
95
+
96
+ Args:
97
+ path: Path to the YAML file.
98
+
99
+ Returns:
100
+ Dictionary with file metadata.
101
+
102
+ """
103
+ try:
104
+ content = yaml.safe_load(path.read_text())
105
+ if not isinstance(content, dict):
106
+ content = {}
107
+ except Exception as e:
108
+ logger.warning("Failed to parse %s: %s", path, e)
109
+ content = {}
110
+
111
+ return {
112
+ "id": content.get("id", path.stem),
113
+ "name": content.get("name", path.stem),
114
+ "description": content.get("description", ""),
115
+ "type": content.get("type"),
116
+ "path": str(path),
117
+ "relative_path": str(path.relative_to(self.root_dir)),
118
+ }
119
+
120
+ def _list_python_tools(self, directory: Path) -> list[dict[str, Any]]:
121
+ """List Python tool files with metadata.
122
+
123
+ Scans for .py files that contain BaseTool subclasses.
124
+
125
+ Args:
126
+ directory: Directory to scan for Python files.
127
+
128
+ Returns:
129
+ List of file metadata dictionaries.
130
+
131
+ """
132
+ files = []
133
+ if not directory.exists():
134
+ return files
135
+
136
+ for path in directory.glob("*.py"):
137
+ # Skip dunder files
138
+ if path.name.startswith("_"):
139
+ continue
140
+
141
+ try:
142
+ source = path.read_text()
143
+ tree = ast.parse(source)
144
+
145
+ # Find classes that might be BaseTool subclasses
146
+ for node in ast.walk(tree):
147
+ if isinstance(node, ast.ClassDef):
148
+ # Check if it inherits from BaseTool
149
+ for base in node.bases:
150
+ base_name = ""
151
+ if isinstance(base, ast.Name):
152
+ base_name = base.id
153
+ elif isinstance(base, ast.Attribute):
154
+ base_name = base.attr
155
+
156
+ if base_name == "BaseTool":
157
+ # Convert class name to snake_case for tool type
158
+ class_name = node.name
159
+ s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", class_name)
160
+ tool_type = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
161
+
162
+ # Extract docstring
163
+ docstring = ast.get_docstring(node) or ""
164
+ description = docstring.split("\n")[0] if docstring else ""
165
+
166
+ files.append(
167
+ {
168
+ "id": tool_type,
169
+ "name": class_name,
170
+ "description": description,
171
+ "type": "python",
172
+ "path": str(path),
173
+ "relative_path": str(path.relative_to(self.root_dir)),
174
+ }
175
+ )
176
+ break # Only add once per file
177
+
178
+ except Exception as e:
179
+ logger.warning("Failed to parse Python tool %s: %s", path, e)
180
+ continue
181
+
182
+ files.sort(key=lambda x: x.get("name", x.get("id", "")))
183
+ return files
184
+
185
+ def ensure_directories(self) -> list[Path]:
186
+ """Ensure all standard directories exist.
187
+
188
+ Returns:
189
+ List of created directories.
190
+
191
+ """
192
+ created = []
193
+ for directory in [
194
+ self.scenarios_dir,
195
+ self.tools_dir,
196
+ self.agents_dir,
197
+ self.runs_dir,
198
+ self.datasets_dir,
199
+ ]:
200
+ if not directory.exists():
201
+ directory.mkdir(parents=True, exist_ok=True)
202
+ created.append(directory)
203
+ logger.info("Created directory: %s", directory)
204
+ return created
205
+
206
+
207
+ def get_local_context() -> LocalContext | None:
208
+ """Get the current local context.
209
+
210
+ Returns:
211
+ The active LocalContext or None if not in local mode.
212
+
213
+ """
214
+ return _local_context
215
+
216
+
217
+ def set_local_context(ctx: LocalContext | None) -> None:
218
+ """Set the local context.
219
+
220
+ Args:
221
+ ctx: LocalContext to set, or None to clear.
222
+
223
+ """
224
+ global _local_context
225
+ _local_context = ctx
226
+
227
+
228
+ def is_local_mode() -> bool:
229
+ """Check if running in local mode.
230
+
231
+ Returns:
232
+ True if a local context is active.
233
+
234
+ """
235
+ return _local_context is not None
@@ -0,0 +1,173 @@
1
+ """Local results storage for Sandboxy runs.
2
+
3
+ This module provides functions for persisting, retrieving, and managing
4
+ scenario run results in the local runs/ directory. Results are stored
5
+ as JSON files with timestamps for easy tracking and analysis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from sandboxy.local.context import get_local_context
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def save_run_result(
22
+ scenario_id: str,
23
+ result: dict[str, Any] | Any,
24
+ metadata: dict[str, Any] | None = None,
25
+ ) -> Path:
26
+ """Save a run result to the local runs/ directory.
27
+
28
+ Args:
29
+ scenario_id: Identifier for the scenario that was run.
30
+ result: The result data to save. If it has a to_dict() method, it will be called.
31
+ metadata: Optional additional metadata to include.
32
+
33
+ Returns:
34
+ Path to the saved result file.
35
+
36
+ Raises:
37
+ RuntimeError: If not in local mode.
38
+
39
+ """
40
+ ctx = get_local_context()
41
+ if not ctx:
42
+ raise RuntimeError("Not in local mode - cannot save results")
43
+
44
+ # Ensure runs directory exists
45
+ ctx.runs_dir.mkdir(exist_ok=True)
46
+
47
+ # Generate filename with timestamp
48
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
49
+ filename = f"{scenario_id}_{timestamp}.json"
50
+ filepath = ctx.runs_dir / filename
51
+
52
+ # Convert result to dict if needed
53
+ if hasattr(result, "to_dict"):
54
+ result_data = result.to_dict()
55
+ elif hasattr(result, "__dict__"):
56
+ result_data = result.__dict__
57
+ else:
58
+ result_data = result
59
+
60
+ # Build output structure
61
+ output = {
62
+ "scenario_id": scenario_id,
63
+ "timestamp": datetime.now().isoformat(),
64
+ "result": result_data,
65
+ "metadata": metadata or {},
66
+ }
67
+
68
+ # Write to file
69
+ filepath.write_text(json.dumps(output, indent=2, default=str))
70
+ logger.info("Saved run result to %s", filepath)
71
+
72
+ return filepath
73
+
74
+
75
+ def list_run_results(
76
+ limit: int = 100,
77
+ scenario_id: str | None = None,
78
+ ) -> list[dict[str, Any]]:
79
+ """List run results from the local runs/ directory.
80
+
81
+ Args:
82
+ limit: Maximum number of results to return.
83
+ scenario_id: Optional filter by scenario ID.
84
+
85
+ Returns:
86
+ List of run result summaries, most recent first.
87
+
88
+ """
89
+ ctx = get_local_context()
90
+ if not ctx:
91
+ return []
92
+
93
+ if not ctx.runs_dir.exists():
94
+ return []
95
+
96
+ results = []
97
+ for path in sorted(ctx.runs_dir.glob("*.json"), reverse=True):
98
+ if len(results) >= limit:
99
+ break
100
+
101
+ try:
102
+ data = json.loads(path.read_text())
103
+
104
+ # Filter by scenario_id if specified
105
+ if scenario_id and data.get("scenario_id") != scenario_id:
106
+ continue
107
+
108
+ results.append(
109
+ {
110
+ "filename": path.name,
111
+ "path": str(path),
112
+ "scenario_id": data.get("scenario_id"),
113
+ "timestamp": data.get("timestamp"),
114
+ "metadata": data.get("metadata", {}),
115
+ }
116
+ )
117
+ except Exception as e:
118
+ logger.warning("Failed to read run result %s: %s", path, e)
119
+ continue
120
+
121
+ return results
122
+
123
+
124
+ def get_run_result(filename: str) -> dict[str, Any] | None:
125
+ """Get a specific run result by filename.
126
+
127
+ Args:
128
+ filename: The filename of the run result.
129
+
130
+ Returns:
131
+ The full run result data, or None if not found.
132
+
133
+ """
134
+ ctx = get_local_context()
135
+ if not ctx:
136
+ return None
137
+
138
+ filepath = ctx.runs_dir / filename
139
+ if not filepath.exists():
140
+ return None
141
+
142
+ try:
143
+ return json.loads(filepath.read_text())
144
+ except Exception as e:
145
+ logger.warning("Failed to read run result %s: %s", filepath, e)
146
+ return None
147
+
148
+
149
+ def delete_run_result(filename: str) -> bool:
150
+ """Delete a run result by filename.
151
+
152
+ Args:
153
+ filename: The filename of the run result to delete.
154
+
155
+ Returns:
156
+ True if deleted, False if not found or error.
157
+
158
+ """
159
+ ctx = get_local_context()
160
+ if not ctx:
161
+ return False
162
+
163
+ filepath = ctx.runs_dir / filename
164
+ if not filepath.exists():
165
+ return False
166
+
167
+ try:
168
+ filepath.unlink()
169
+ logger.info("Deleted run result %s", filepath)
170
+ return True
171
+ except Exception as e:
172
+ logger.warning("Failed to delete run result %s: %s", filepath, e)
173
+ return False
sandboxy/logging.py ADDED
@@ -0,0 +1,31 @@
1
+ """Logging configuration for Sandboxy."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+
7
+ def setup_logging(level: int = logging.INFO, verbose: bool = False) -> None:
8
+ """Configure logging for Sandboxy.
9
+
10
+ Args:
11
+ level: Base logging level.
12
+ verbose: If True, use DEBUG level and show module names.
13
+ """
14
+ if verbose:
15
+ level = logging.DEBUG
16
+ fmt = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
17
+ else:
18
+ fmt = "%(levelname)s: %(message)s"
19
+
20
+ logging.basicConfig(
21
+ level=level,
22
+ format=fmt,
23
+ stream=sys.stderr,
24
+ force=True,
25
+ )
26
+
27
+ # Quiet noisy libraries
28
+ logging.getLogger("httpx").setLevel(logging.WARNING)
29
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
30
+ logging.getLogger("openai").setLevel(logging.WARNING)
31
+ logging.getLogger("anthropic").setLevel(logging.WARNING)
@@ -0,0 +1,25 @@
1
+ """MCP (Model Context Protocol) integration for Sandboxy.
2
+
3
+ This module provides support for connecting to real MCP servers and using
4
+ their tools alongside YAML mock tools in scenarios.
5
+
6
+ Supports two transport types:
7
+ - stdio: Local servers run as subprocesses (e.g., npx, python scripts)
8
+ - HTTP: Remote servers accessed via SSE or Streamable HTTP
9
+ """
10
+
11
+ from sandboxy.mcp.client import (
12
+ McpManager,
13
+ McpServerConfig,
14
+ inspect_mcp_server,
15
+ inspect_mcp_server_http,
16
+ )
17
+ from sandboxy.mcp.wrapper import McpToolWrapper
18
+
19
+ __all__ = [
20
+ "McpManager",
21
+ "McpServerConfig",
22
+ "McpToolWrapper",
23
+ "inspect_mcp_server",
24
+ "inspect_mcp_server_http",
25
+ ]