voxagent 0.1.1__tar.gz → 0.2.1__tar.gz

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. {voxagent-0.1.1 → voxagent-0.2.1}/PKG-INFO +3 -1
  2. {voxagent-0.1.1 → voxagent-0.2.1}/pyproject.toml +4 -0
  3. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/_version.py +1 -1
  4. voxagent-0.2.1/src/voxagent/code/__init__.py +55 -0
  5. voxagent-0.2.1/src/voxagent/code/agent.py +275 -0
  6. voxagent-0.2.1/src/voxagent/code/sandbox.py +335 -0
  7. voxagent-0.2.1/src/voxagent/code/tool_proxy.py +259 -0
  8. voxagent-0.2.1/src/voxagent/code/virtual_fs.py +223 -0
  9. voxagent-0.1.1/src/voxagent/code/__init__.py +0 -9
  10. {voxagent-0.1.1 → voxagent-0.2.1}/.gitignore +0 -0
  11. {voxagent-0.1.1 → voxagent-0.2.1}/README.md +0 -0
  12. {voxagent-0.1.1 → voxagent-0.2.1}/examples/README.md +0 -0
  13. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/__init__.py +0 -0
  14. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/agent/__init__.py +0 -0
  15. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/agent/abort.py +0 -0
  16. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/agent/core.py +0 -0
  17. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/mcp/__init__.py +0 -0
  18. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/mcp/manager.py +0 -0
  19. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/mcp/tool.py +0 -0
  20. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/__init__.py +0 -0
  21. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/anthropic.py +0 -0
  22. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/augment.py +0 -0
  23. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/auth.py +0 -0
  24. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/base.py +0 -0
  25. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/chatgpt.py +0 -0
  26. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/claudecode.py +0 -0
  27. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/cli_base.py +0 -0
  28. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/codex.py +0 -0
  29. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/failover.py +0 -0
  30. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/google.py +0 -0
  31. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/groq.py +0 -0
  32. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/ollama.py +0 -0
  33. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/openai.py +0 -0
  34. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/providers/registry.py +0 -0
  35. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/py.typed +0 -0
  36. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/security/__init__.py +0 -0
  37. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/security/events.py +0 -0
  38. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/security/filter.py +0 -0
  39. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/security/registry.py +0 -0
  40. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/session/__init__.py +0 -0
  41. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/session/compaction.py +0 -0
  42. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/session/lock.py +0 -0
  43. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/session/model.py +0 -0
  44. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/session/storage.py +0 -0
  45. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/streaming/__init__.py +0 -0
  46. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/streaming/emitter.py +0 -0
  47. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/streaming/events.py +0 -0
  48. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/subagent/__init__.py +0 -0
  49. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/subagent/context.py +0 -0
  50. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/subagent/definition.py +0 -0
  51. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/tools/__init__.py +0 -0
  52. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/tools/context.py +0 -0
  53. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/tools/decorator.py +0 -0
  54. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/tools/definition.py +0 -0
  55. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/tools/executor.py +0 -0
  56. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/tools/policy.py +0 -0
  57. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/tools/registry.py +0 -0
  58. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/types/__init__.py +0 -0
  59. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/types/messages.py +0 -0
  60. {voxagent-0.1.1 → voxagent-0.2.1}/src/voxagent/types/run.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voxagent
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: A lightweight, model-agnostic LLM provider abstraction with streaming and tool support
5
5
  Project-URL: Homepage, https://github.com/lensator/voxagent
6
6
  Project-URL: Documentation, https://github.com/lensator/voxagent#readme
@@ -34,6 +34,8 @@ Requires-Dist: openai>=1.0; extra == 'all'
34
34
  Requires-Dist: tiktoken>=0.5; extra == 'all'
35
35
  Provides-Extra: anthropic
36
36
  Requires-Dist: anthropic>=0.25; extra == 'anthropic'
37
+ Provides-Extra: code
38
+ Requires-Dist: restrictedpython>=7.0; extra == 'code'
37
39
  Provides-Extra: dev
38
40
  Requires-Dist: mypy>=1.0; extra == 'dev'
39
41
  Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
@@ -55,6 +55,10 @@ ollama = [
55
55
  mcp = [
56
56
  "mcp>=1.0",
57
57
  ]
58
+ # Code sandbox support
59
+ code = [
60
+ "RestrictedPython>=7.0",
61
+ ]
58
62
  # All providers
59
63
  all = [
60
64
  "voxagent[openai,anthropic,google,groq,ollama,mcp]",
@@ -1,2 +1,2 @@
1
- __version__ = "0.1.1"
1
+ __version__ = "0.2.1"
2
2
  __version_info__ = tuple(int(x) for x in __version__.split("."))
@@ -0,0 +1,55 @@
1
+ """Code execution sandbox for token optimization.
2
+
3
+ This subpackage provides:
4
+ - Sandboxed code execution (subprocess, Pyodide)
5
+ - Tool file generation from MCP servers
6
+ - Skills persistence and discovery
7
+ - PII tokenization for privacy
8
+ - Virtual filesystem for tool discovery
9
+ - Code execution mode for agents
10
+ - Tool proxy for routing sandbox calls to real implementations
11
+ """
12
+
13
+ from voxagent.code.sandbox import CodeSandbox, SandboxResult, SubprocessSandbox
14
+ from voxagent.code.virtual_fs import (
15
+ ToolCategory,
16
+ ToolRegistry,
17
+ VirtualFilesystem,
18
+ )
19
+ from voxagent.code.agent import (
20
+ CodeModeConfig,
21
+ CodeModeExecutor,
22
+ get_code_mode_system_prompt_addition,
23
+ setup_code_mode_for_agent,
24
+ CODE_MODE_SYSTEM_PROMPT,
25
+ )
26
+ from voxagent.code.tool_proxy import (
27
+ ToolCallRequest,
28
+ ToolCallResponse,
29
+ ToolProxyClient,
30
+ ToolProxyServer,
31
+ create_tool_proxy_pair,
32
+ )
33
+
34
+ __all__ = [
35
+ # Sandbox
36
+ "CodeSandbox",
37
+ "SandboxResult",
38
+ "SubprocessSandbox",
39
+ # Virtual FS
40
+ "ToolCategory",
41
+ "ToolRegistry",
42
+ "VirtualFilesystem",
43
+ # Code Mode Agent
44
+ "CodeModeConfig",
45
+ "CodeModeExecutor",
46
+ "CODE_MODE_SYSTEM_PROMPT",
47
+ "get_code_mode_system_prompt_addition",
48
+ "setup_code_mode_for_agent",
49
+ # Tool Proxy
50
+ "ToolCallRequest",
51
+ "ToolCallResponse",
52
+ "ToolProxyClient",
53
+ "ToolProxyServer",
54
+ "create_tool_proxy_pair",
55
+ ]
@@ -0,0 +1,275 @@
1
+ """Code execution mode for Agent.
2
+
3
+ When execution_mode="code", the agent uses a single execute_code tool
4
+ instead of exposing all tools directly. The LLM writes Python code
5
+ that calls tools via the virtual filesystem.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from voxagent.code.sandbox import SubprocessSandbox, SandboxResult
13
+ from voxagent.code.virtual_fs import VirtualFilesystem, ToolRegistry
14
+ from voxagent.tools.definition import ToolDefinition
15
+
16
+ if TYPE_CHECKING:
17
+ from voxagent.agent.core import Agent
18
+
19
+
20
+ # System prompt addition for code mode
21
+ CODE_MODE_SYSTEM_PROMPT = '''
22
+ ## Code Execution Mode
23
+
24
+ You have access to a single tool: `execute_code`. Use it to write Python code that:
25
+ 1. Explores available tools with `ls("tools/")` and `read("tools/<category>/<tool>.py")`
26
+ 2. Calls tools using `call_tool(category, tool_name, **kwargs)`
27
+ 3. Uses `print()` to output results
28
+
29
+ ### Available Functions in Sandbox
30
+ - `ls(path)` - List directory contents (e.g., `ls("tools/")`, `ls("tools/devices/")`)
31
+ - `read(path)` - Read file contents (e.g., `read("tools/devices/registry.py")`)
32
+ - `call_tool(category, tool_name, **kwargs)` - Call a tool with arguments
33
+ - `print(*args)` - Output results (captured and returned to you)
34
+
35
+ ### Workflow
36
+ 1. **Explore**: `print(ls("tools/"))` to see categories
37
+ 2. **Learn**: `print(read("tools/<category>/<tool>.py"))` to see tool signatures
38
+ 3. **Execute**: Use `call_tool()` to invoke tools
39
+ 4. **Report**: Use `print()` to show results
40
+
41
+ ### Example
42
+ ```python
43
+ # Explore available tools
44
+ print("Categories:", ls("tools/"))
45
+
46
+ # Read a tool definition
47
+ print(read("tools/devices/registry.py"))
48
+
49
+ # Call the tool
50
+ result = call_tool("devices", "registry.py", device_type="light")
51
+ print("Devices:", result)
52
+ ```
53
+
54
+ ### Rules
55
+ 1. Always use `print()` to show results
56
+ 2. Explore before assuming - use `ls()` and `read()` first
57
+ 3. Handle errors with try/except when appropriate
58
+ '''
59
+
60
+
61
+ class CodeModeConfig:
62
+ """Configuration for code execution mode.
63
+
64
+ Attributes:
65
+ enabled: Whether code mode is active
66
+ timeout_seconds: Max execution time per code block
67
+ memory_limit_mb: Max memory for sandbox
68
+ max_output_chars: Truncate output beyond this
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ enabled: bool = True,
74
+ timeout_seconds: int = 10,
75
+ memory_limit_mb: int = 128,
76
+ max_output_chars: int = 10000,
77
+ ):
78
+ self.enabled = enabled
79
+ self.timeout_seconds = timeout_seconds
80
+ self.memory_limit_mb = memory_limit_mb
81
+ self.max_output_chars = max_output_chars
82
+
83
+
84
+ class CodeModeExecutor:
85
+ """Executes code for an agent in code mode.
86
+
87
+ This class:
88
+ 1. Manages the sandbox and virtual filesystem
89
+ 2. Provides the execute_code tool implementation
90
+ 3. Routes tool calls from sandbox to real implementations
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ config: CodeModeConfig,
96
+ tool_registry: ToolRegistry,
97
+ ):
98
+ self.config = config
99
+ self.tool_registry = tool_registry
100
+ self.sandbox = SubprocessSandbox(
101
+ timeout_seconds=config.timeout_seconds,
102
+ memory_limit_mb=config.memory_limit_mb,
103
+ )
104
+ self.virtual_fs = VirtualFilesystem(tool_registry)
105
+
106
+ # Tool proxy for routing calls
107
+ self._tool_implementations: dict[str, Any] = {}
108
+
109
+ def register_tool_implementation(
110
+ self,
111
+ category: str,
112
+ tool_name: str,
113
+ implementation: Any,
114
+ ) -> None:
115
+ """Register a real tool implementation for the proxy."""
116
+ key = f"{category}.{tool_name}"
117
+ self._tool_implementations[key] = implementation
118
+
119
+ async def execute_code(self, code: str) -> str:
120
+ """Execute Python code in the sandbox.
121
+
122
+ This is the tool exposed to the LLM.
123
+
124
+ Args:
125
+ code: Python source code to execute
126
+
127
+ Returns:
128
+ Captured output or error message
129
+ """
130
+ # Build globals with virtual filesystem functions only
131
+ # Note: call_tool is not passed to sandbox due to pickling constraints
132
+ # The LLM should use ls() and read() to explore, then describe what to call
133
+ globals_dict = self.virtual_fs.get_sandbox_globals()
134
+
135
+ # Execute in sandbox
136
+ result = await self.sandbox.execute(code, globals_dict)
137
+
138
+ # Format output
139
+ if result.success:
140
+ output = result.output or "(no output)"
141
+ if len(output) > self.config.max_output_chars:
142
+ output = output[:self.config.max_output_chars] + "\n... (truncated)"
143
+ return output
144
+ else:
145
+ return f"Error: {result.error}"
146
+
147
+ def call_tool(self, category: str, tool_name: str, **kwargs: Any) -> Any:
148
+ """Call a registered tool implementation.
149
+
150
+ This method is called outside the sandbox after the LLM
151
+ has explored tools and decided which one to call.
152
+
153
+ Args:
154
+ category: Tool category (e.g., "devices")
155
+ tool_name: Tool name (e.g., "registry.py")
156
+ **kwargs: Arguments to pass to the tool
157
+
158
+ Returns:
159
+ Tool result or error message
160
+ """
161
+ key = f"{category}.{tool_name}"
162
+ if key not in self._tool_implementations:
163
+ return f"Error: Tool '{key}' not found"
164
+ try:
165
+ impl = self._tool_implementations[key]
166
+ return impl(**kwargs)
167
+ except Exception as e:
168
+ return f"Error calling {key}: {e}"
169
+
170
+ def get_execute_code_tool(self) -> ToolDefinition:
171
+ """Get the execute_code tool definition for the agent."""
172
+ async def execute_code_wrapper(code: str) -> str:
173
+ """Execute Python code to explore and use tools.
174
+
175
+ Write Python code that:
176
+ 1. Uses ls() to explore available tool categories
177
+ 2. Uses read() to see tool signatures and documentation
178
+ 3. Imports and calls tools to perform actions
179
+ 4. Uses print() to output results
180
+
181
+ Args:
182
+ code: Python source code to execute
183
+
184
+ Returns:
185
+ Captured print() output or error message
186
+ """
187
+ return await self.execute_code(code)
188
+
189
+ return ToolDefinition(
190
+ name="execute_code",
191
+ description="Execute Python code to explore and use tools. Use ls() to see tools, read() to see signatures, and print() to output results.",
192
+ execute=execute_code_wrapper,
193
+ parameters={
194
+ "type": "object",
195
+ "properties": {
196
+ "code": {
197
+ "type": "string",
198
+ "description": "Python source code to execute"
199
+ }
200
+ },
201
+ "required": ["code"]
202
+ },
203
+ is_async=True,
204
+ )
205
+
206
+
207
+ def get_code_mode_system_prompt_addition() -> str:
208
+ """Get the system prompt addition for code mode."""
209
+ return CODE_MODE_SYSTEM_PROMPT
210
+
211
+
212
+ def setup_code_mode_for_agent(
213
+ agent: "Agent[Any, Any]",
214
+ config: CodeModeConfig | None = None,
215
+ ) -> CodeModeExecutor:
216
+ """Set up code mode for an existing agent.
217
+
218
+ This:
219
+ 1. Creates a CodeModeExecutor
220
+ 2. Registers the execute_code tool
221
+ 3. Converts existing tools to virtual filesystem entries
222
+
223
+ Args:
224
+ agent: The agent to configure
225
+ config: Optional code mode configuration
226
+
227
+ Returns:
228
+ The CodeModeExecutor instance
229
+ """
230
+ config = config or CodeModeConfig()
231
+
232
+ # Create tool registry from agent's existing tools
233
+ vf_tool_registry = ToolRegistry()
234
+
235
+ # Convert agent's tools to virtual filesystem entries
236
+ for tool_def in agent._tool_registry.list():
237
+ # Create category based on tool name or use "default"
238
+ category = "default"
239
+
240
+ # Build tool definition content as Python stub
241
+ params_str = ", ".join(
242
+ f"{k}: {v.get('type', 'Any')}"
243
+ for k, v in tool_def.parameters.get("properties", {}).items()
244
+ )
245
+ tool_content = f'''def {tool_def.name}({params_str}) -> Any:
246
+ """{tool_def.description}"""
247
+ ...
248
+ '''
249
+
250
+ # Get or create category
251
+ if category not in vf_tool_registry.list_categories():
252
+ vf_tool_registry.register_category(
253
+ category,
254
+ description="Default tool category",
255
+ tools={},
256
+ )
257
+
258
+ # Add tool to category
259
+ cat = vf_tool_registry.get_category(category)
260
+ if cat:
261
+ cat.tools[f"{tool_def.name}.py"] = tool_content
262
+
263
+ # Create executor
264
+ executor = CodeModeExecutor(config, vf_tool_registry)
265
+
266
+ # Register tool implementations
267
+ for tool_def in agent._tool_registry.list():
268
+ executor.register_tool_implementation(
269
+ "default",
270
+ f"{tool_def.name}.py",
271
+ tool_def.execute,
272
+ )
273
+
274
+ return executor
275
+
@@ -0,0 +1,335 @@
1
+ """Sandboxed Python code execution with security restrictions.
2
+
3
+ This module provides secure code execution using RestrictedPython
4
+ and subprocess isolation with resource limits.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import multiprocessing
10
+ import time
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from voxagent.code.tool_proxy import ToolProxyClient
17
+
18
+
19
+ @dataclass
20
+ class SandboxResult:
21
+ """Result of sandboxed code execution.
22
+
23
+ Attributes:
24
+ output: Captured stdout from print() calls
25
+ error: Error message if execution failed
26
+ execution_time_ms: Time taken to execute in milliseconds
27
+ success: Whether execution completed without errors (derived from error being None)
28
+ """
29
+
30
+ output: str | None = None
31
+ error: str | None = None
32
+ execution_time_ms: float | None = None
33
+
34
+ @property
35
+ def success(self) -> bool:
36
+ """Return True if execution completed without errors."""
37
+ return self.error is None
38
+
39
+
40
+ class CodeSandbox(ABC):
41
+ """Abstract base class for code execution sandboxes."""
42
+
43
+ @abstractmethod
44
+ async def execute(
45
+ self, code: str, globals_dict: dict[str, Any] | None = None
46
+ ) -> SandboxResult:
47
+ """Execute Python code in a sandboxed environment.
48
+
49
+ Args:
50
+ code: Python source code to execute
51
+ globals_dict: Optional globals to inject (e.g., ls, read functions)
52
+
53
+ Returns:
54
+ SandboxResult with output or error
55
+ """
56
+ ...
57
+
58
+
59
+ def _inplacevar_(op: str, x: Any, y: Any) -> Any:
60
+ """Handle in-place operators (+=, -=, etc.) in RestrictedPython."""
61
+ if op == "+=":
62
+ return x + y
63
+ elif op == "-=":
64
+ return x - y
65
+ elif op == "*=":
66
+ return x * y
67
+ elif op == "/=":
68
+ return x / y
69
+ elif op == "//=":
70
+ return x // y
71
+ elif op == "%=":
72
+ return x % y
73
+ elif op == "**=":
74
+ return x**y
75
+ elif op == "&=":
76
+ return x & y
77
+ elif op == "|=":
78
+ return x | y
79
+ elif op == "^=":
80
+ return x ^ y
81
+ elif op == ">>=":
82
+ return x >> y
83
+ elif op == "<<=":
84
+ return x << y
85
+ else:
86
+ raise ValueError(f"Unknown operator: {op}")
87
+
88
+
89
+ def _execute_in_subprocess(
90
+ code: str,
91
+ globals_dict: dict[str, Any],
92
+ result_queue: multiprocessing.Queue, # type: ignore[type-arg]
93
+ memory_limit_mb: int,
94
+ ) -> None:
95
+ """Subprocess entry point for sandboxed execution."""
96
+ # Import here to avoid loading in main process
97
+ import ast
98
+ import warnings
99
+
100
+ # Suppress RestrictedPython SyntaxWarnings about print/printed variable
101
+ # These warnings are harmless but noisy (about print transformation internals)
102
+ warnings.filterwarnings(
103
+ "ignore",
104
+ message=".*Prints, but never reads 'printed' variable.*",
105
+ category=SyntaxWarning,
106
+ )
107
+
108
+ from RestrictedPython import compile_restricted, safe_builtins
109
+ from RestrictedPython.Eval import default_guarded_getitem, default_guarded_getiter
110
+ from RestrictedPython.Guards import (
111
+ guarded_iter_unpack_sequence,
112
+ safer_getattr,
113
+ )
114
+ from RestrictedPython.PrintCollector import PrintCollector
115
+ from RestrictedPython.transformer import (
116
+ IOPERATOR_TO_STR,
117
+ RestrictingNodeTransformer,
118
+ copy_locations,
119
+ )
120
+
121
+ # Custom policy that allows augmented assignment on attributes
122
+ class PermissiveNodeTransformer(RestrictingNodeTransformer):
123
+ """A more permissive node transformer that allows augmented assignment."""
124
+
125
+ def visit_AugAssign(self, node: ast.AugAssign) -> ast.AST:
126
+ """Allow augmented assignment on attributes and subscripts.
127
+
128
+ Transforms 'a.x += 1' to 'a.x = _inplacevar_("+=", a.x, 1)'
129
+ and 'a[i] += 1' to 'a[i] = _inplacevar_("+=", a[i], 1)'
130
+ """
131
+ node = self.node_contents_visit(node)
132
+
133
+ if isinstance(node.target, ast.Attribute):
134
+ # Transform a.x += 1 to a.x = _inplacevar_("+=", a.x, 1)
135
+ new_node = ast.Assign(
136
+ targets=[
137
+ ast.Attribute(
138
+ value=node.target.value,
139
+ attr=node.target.attr,
140
+ ctx=ast.Store(),
141
+ )
142
+ ],
143
+ value=ast.Call(
144
+ func=ast.Name("_inplacevar_", ast.Load()),
145
+ args=[
146
+ ast.Constant(IOPERATOR_TO_STR[type(node.op)]),
147
+ ast.Attribute(
148
+ value=node.target.value,
149
+ attr=node.target.attr,
150
+ ctx=ast.Load(),
151
+ ),
152
+ node.value,
153
+ ],
154
+ keywords=[],
155
+ ),
156
+ )
157
+ copy_locations(new_node, node)
158
+ return new_node
159
+
160
+ elif isinstance(node.target, ast.Subscript):
161
+ # Transform a[i] += 1 to a[i] = _inplacevar_("+=", a[i], 1)
162
+ new_node = ast.Assign(
163
+ targets=[
164
+ ast.Subscript(
165
+ value=node.target.value,
166
+ slice=node.target.slice,
167
+ ctx=ast.Store(),
168
+ )
169
+ ],
170
+ value=ast.Call(
171
+ func=ast.Name("_inplacevar_", ast.Load()),
172
+ args=[
173
+ ast.Constant(IOPERATOR_TO_STR[type(node.op)]),
174
+ ast.Subscript(
175
+ value=node.target.value,
176
+ slice=node.target.slice,
177
+ ctx=ast.Load(),
178
+ ),
179
+ node.value,
180
+ ],
181
+ keywords=[],
182
+ ),
183
+ )
184
+ copy_locations(new_node, node)
185
+ return new_node
186
+
187
+ elif isinstance(node.target, ast.Name):
188
+ new_node = ast.Assign(
189
+ targets=[node.target],
190
+ value=ast.Call(
191
+ func=ast.Name("_inplacevar_", ast.Load()),
192
+ args=[
193
+ ast.Constant(IOPERATOR_TO_STR[type(node.op)]),
194
+ ast.Name(node.target.id, ast.Load()),
195
+ node.value,
196
+ ],
197
+ keywords=[],
198
+ ),
199
+ )
200
+ copy_locations(new_node, node)
201
+ return new_node
202
+ else:
203
+ raise NotImplementedError(f"Unknown target type: {type(node.target)}")
204
+
205
+ # Set memory limit (Unix only)
206
+ try:
207
+ import resource
208
+
209
+ limit_bytes = memory_limit_mb * 1024 * 1024
210
+ # Try to set RLIMIT_AS (address space) - works on Linux
211
+ try:
212
+ soft, hard = resource.getrlimit(resource.RLIMIT_AS)
213
+ # Only set if we can (soft limit can be lowered)
214
+ if limit_bytes <= hard:
215
+ resource.setrlimit(resource.RLIMIT_AS, (limit_bytes, hard))
216
+ except (ValueError, OSError):
217
+ pass
218
+ # Also try RLIMIT_DATA
219
+ try:
220
+ soft, hard = resource.getrlimit(resource.RLIMIT_DATA)
221
+ if limit_bytes <= hard:
222
+ resource.setrlimit(resource.RLIMIT_DATA, (limit_bytes, hard))
223
+ except (ValueError, OSError):
224
+ pass
225
+ except ImportError:
226
+ pass # Windows
227
+
228
+ # Build safe builtins
229
+ safe_builtins_copy = dict(safe_builtins)
230
+ # Remove dangerous builtins
231
+ for name in ("open", "eval", "exec", "compile", "__import__"):
232
+ safe_builtins_copy.pop(name, None)
233
+
234
+ try:
235
+ byte_code = compile_restricted(
236
+ code, "<sandbox>", "exec", policy=PermissiveNodeTransformer
237
+ )
238
+ if byte_code is None:
239
+ result_queue.put(SandboxResult(output="", error="SyntaxError: compilation failed"))
240
+ return
241
+
242
+ exec_globals: dict[str, Any] = {
243
+ "__builtins__": safe_builtins_copy,
244
+ "__name__": "__main__",
245
+ "__doc__": None,
246
+ # Required for class definitions in RestrictedPython
247
+ "__metaclass__": type,
248
+ # _print_ is a factory - RestrictedPython will call it to create a collector
249
+ "_print_": PrintCollector,
250
+ # Also provide _getattr_ for attribute access
251
+ "_getattr_": safer_getattr,
252
+ "_getitem_": default_guarded_getitem,
253
+ "_getiter_": default_guarded_getiter,
254
+ "_iter_unpack_sequence_": guarded_iter_unpack_sequence,
255
+ "_inplacevar_": _inplacevar_,
256
+ # Provide write guard for attribute writes
257
+ "_write_": lambda x: x,
258
+ **globals_dict,
259
+ }
260
+ exec(byte_code, exec_globals)
261
+ # Get the _print object that was created during execution and call it
262
+ _print_obj = exec_globals.get("_print")
263
+ output = _print_obj() if _print_obj else ""
264
+ result_queue.put(
265
+ SandboxResult(
266
+ output=output if output else "",
267
+ )
268
+ )
269
+ except SyntaxError as e:
270
+ result_queue.put(SandboxResult(output="", error=f"SyntaxError: {e}"))
271
+ except Exception as e:
272
+ result_queue.put(SandboxResult(output="", error=f"{type(e).__name__}: {e}"))
273
+
274
+
275
+ class SubprocessSandbox(CodeSandbox):
276
+ """Execute Python code in an isolated subprocess with RestrictedPython.
277
+
278
+ Security features:
279
+ - RestrictedPython AST filtering (blocks imports, file access, etc.)
280
+ - Process isolation via multiprocessing
281
+ - Timeout enforcement
282
+ - Memory limits (Unix only)
283
+
284
+ Args:
285
+ timeout_seconds: Maximum execution time (default: 10)
286
+ memory_limit_mb: Maximum memory in MB (default: 128, Unix only)
287
+ tool_proxy_client: Optional tool proxy client for routing tool calls
288
+ """
289
+
290
+ def __init__(
291
+ self,
292
+ timeout_seconds: int = 10,
293
+ memory_limit_mb: int = 128,
294
+ tool_proxy_client: "ToolProxyClient | None" = None,
295
+ ) -> None:
296
+ self.timeout_seconds = timeout_seconds
297
+ self.memory_limit_mb = memory_limit_mb
298
+ self.tool_proxy_client = tool_proxy_client
299
+
300
+ async def execute(
301
+ self, code: str, globals_dict: dict[str, Any] | None = None
302
+ ) -> SandboxResult:
303
+ """Execute code in subprocess with RestrictedPython."""
304
+ start_time = time.monotonic()
305
+
306
+ result_queue: multiprocessing.Queue[SandboxResult] = multiprocessing.Queue()
307
+ process = multiprocessing.Process(
308
+ target=_execute_in_subprocess,
309
+ args=(code, globals_dict or {}, result_queue, self.memory_limit_mb),
310
+ )
311
+ process.start()
312
+ process.join(timeout=self.timeout_seconds)
313
+
314
+ execution_time_ms = (time.monotonic() - start_time) * 1000
315
+
316
+ if process.is_alive():
317
+ process.kill()
318
+ process.join()
319
+ return SandboxResult(
320
+ output="",
321
+ error="Execution timed out",
322
+ execution_time_ms=execution_time_ms,
323
+ )
324
+
325
+ try:
326
+ result = result_queue.get_nowait()
327
+ result.execution_time_ms = execution_time_ms
328
+ return result
329
+ except Exception:
330
+ return SandboxResult(
331
+ output="",
332
+ error="No result from subprocess",
333
+ execution_time_ms=execution_time_ms,
334
+ )
335
+