voxagent 0.1.1__py3-none-any.whl → 0.2.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.
voxagent/_version.py CHANGED
@@ -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("."))
voxagent/code/__init__.py CHANGED
@@ -5,5 +5,51 @@ This subpackage provides:
5
5
  - Tool file generation from MCP servers
6
6
  - Skills persistence and discovery
7
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
8
11
  """
9
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
+ ]
voxagent/code/agent.py ADDED
@@ -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
+
@@ -0,0 +1,259 @@
1
+ """Tool proxy for routing sandbox calls to real implementations.
2
+
3
+ The sandbox cannot directly call async MCP tools. This module provides
4
+ a queue-based proxy that:
5
+ 1. Captures tool calls in the sandbox
6
+ 2. Serializes them to a multiprocessing queue
7
+ 3. The main process executes real tools and returns results
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import multiprocessing
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from multiprocessing import Queue
17
+ from typing import Any, Callable, Awaitable, TYPE_CHECKING
18
+
19
+
20
+ @dataclass
21
+ class ToolCallRequest:
22
+ """A tool call request from the sandbox.
23
+
24
+ Attributes:
25
+ call_id: Unique identifier for this call
26
+ category: Tool category (e.g., "devices")
27
+ tool_name: Tool function name (e.g., "list_devices")
28
+ args: Positional arguments
29
+ kwargs: Keyword arguments
30
+ """
31
+ call_id: str
32
+ category: str
33
+ tool_name: str
34
+ args: tuple[Any, ...] = field(default_factory=tuple)
35
+ kwargs: dict[str, Any] = field(default_factory=dict)
36
+
37
+
38
+ @dataclass
39
+ class ToolCallResponse:
40
+ """Response from a tool call.
41
+
42
+ Attributes:
43
+ call_id: Matches the request call_id
44
+ success: Whether the call succeeded
45
+ result: The return value (if success)
46
+ error: Error message (if failed)
47
+ """
48
+ call_id: str
49
+ success: bool
50
+ result: Any = None
51
+ error: str | None = None
52
+
53
+
54
+ class ToolProxyClient:
55
+ """Client-side proxy that runs in the sandbox subprocess.
56
+
57
+ This creates callable proxies for each tool that:
58
+ 1. Serialize the call to the request queue
59
+ 2. Wait for the response on the response queue
60
+ 3. Return the result or raise an exception
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ request_queue: Queue, # type: ignore[type-arg]
66
+ response_queue: Queue, # type: ignore[type-arg]
67
+ timeout_seconds: float = 30.0,
68
+ ):
69
+ self.request_queue = request_queue
70
+ self.response_queue = response_queue
71
+ self.timeout_seconds = timeout_seconds
72
+ self._call_counter = 0
73
+
74
+ def create_tool_proxy(self, category: str, tool_name: str) -> Callable[..., Any]:
75
+ """Create a callable proxy for a tool.
76
+
77
+ Args:
78
+ category: Tool category
79
+ tool_name: Tool function name
80
+
81
+ Returns:
82
+ A callable that proxies to the real tool
83
+ """
84
+ def proxy(*args: Any, **kwargs: Any) -> Any:
85
+ # Generate unique call ID
86
+ self._call_counter += 1
87
+ call_id = f"call_{self._call_counter}"
88
+
89
+ # Send request
90
+ request = ToolCallRequest(
91
+ call_id=call_id,
92
+ category=category,
93
+ tool_name=tool_name,
94
+ args=args,
95
+ kwargs=kwargs,
96
+ )
97
+ self.request_queue.put(request)
98
+
99
+ # Wait for response (blocking in subprocess)
100
+ start = time.monotonic()
101
+ while True:
102
+ try:
103
+ response: ToolCallResponse = self.response_queue.get(timeout=0.1)
104
+ if response.call_id == call_id:
105
+ if response.success:
106
+ return response.result
107
+ else:
108
+ raise RuntimeError(f"Tool error: {response.error}")
109
+ except Exception:
110
+ pass
111
+
112
+ if time.monotonic() - start > self.timeout_seconds:
113
+ raise TimeoutError(f"Tool call timed out after {self.timeout_seconds}s")
114
+
115
+ return proxy
116
+
117
+ def build_tools_namespace(self, categories: dict[str, list[str]]) -> Any:
118
+ """Build a 'tools' namespace with proxy modules.
119
+
120
+ Args:
121
+ categories: Dict of category name -> list of tool names
122
+
123
+ Returns:
124
+ A namespace object with category submodules
125
+ """
126
+ class ToolModule:
127
+ pass
128
+
129
+ class ToolsNamespace:
130
+ pass
131
+
132
+ tools = ToolsNamespace()
133
+
134
+ for category, tool_names in categories.items():
135
+ module = ToolModule()
136
+ for tool_name in tool_names:
137
+ proxy = self.create_tool_proxy(category, tool_name)
138
+ setattr(module, tool_name, proxy)
139
+
140
+ # Replace hyphens with underscores for valid Python identifiers
141
+ attr_name = category.replace("-", "_")
142
+ setattr(tools, attr_name, module)
143
+
144
+ return tools
145
+
146
+
147
+ class ToolProxyServer:
148
+ """Server-side proxy that runs in the main process.
149
+
150
+ This:
151
+ 1. Listens for tool call requests from the sandbox
152
+ 2. Executes the real tool implementation
153
+ 3. Sends the response back
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ request_queue: Queue, # type: ignore[type-arg]
159
+ response_queue: Queue, # type: ignore[type-arg]
160
+ ):
161
+ self.request_queue = request_queue
162
+ self.response_queue = response_queue
163
+ self._implementations: dict[str, Callable[..., Any]] = {}
164
+ self._running = False
165
+
166
+ def register_implementation(
167
+ self,
168
+ category: str,
169
+ tool_name: str,
170
+ implementation: Callable[..., Any] | Callable[..., Awaitable[Any]],
171
+ ) -> None:
172
+ """Register a tool implementation.
173
+
174
+ Args:
175
+ category: Tool category
176
+ tool_name: Tool function name
177
+ implementation: The actual callable (sync or async)
178
+ """
179
+ key = f"{category}.{tool_name}"
180
+ self._implementations[key] = implementation
181
+
182
+ async def process_one_request(self, timeout: float = 0.1) -> bool:
183
+ """Process a single request if available.
184
+
185
+ Args:
186
+ timeout: How long to wait for a request
187
+
188
+ Returns:
189
+ True if a request was processed, False if queue was empty
190
+ """
191
+ try:
192
+ request: ToolCallRequest = self.request_queue.get_nowait()
193
+ except Exception:
194
+ return False
195
+
196
+ key = f"{request.category}.{request.tool_name}"
197
+ impl = self._implementations.get(key)
198
+
199
+ if impl is None:
200
+ response = ToolCallResponse(
201
+ call_id=request.call_id,
202
+ success=False,
203
+ error=f"Tool not found: {key}",
204
+ )
205
+ else:
206
+ try:
207
+ # Call the implementation (handle async)
208
+ if asyncio.iscoroutinefunction(impl):
209
+ result = await impl(*request.args, **request.kwargs)
210
+ else:
211
+ result = impl(*request.args, **request.kwargs)
212
+
213
+ response = ToolCallResponse(
214
+ call_id=request.call_id,
215
+ success=True,
216
+ result=result,
217
+ )
218
+ except Exception as e:
219
+ response = ToolCallResponse(
220
+ call_id=request.call_id,
221
+ success=False,
222
+ error=f"{type(e).__name__}: {e}",
223
+ )
224
+
225
+ self.response_queue.put(response)
226
+ return True
227
+
228
+ async def run_until_complete(self, timeout: float = 30.0) -> None:
229
+ """Process requests until timeout or no more requests.
230
+
231
+ Args:
232
+ timeout: Maximum time to run
233
+ """
234
+ start = time.monotonic()
235
+ self._running = True
236
+
237
+ while self._running and (time.monotonic() - start) < timeout:
238
+ processed = await self.process_one_request()
239
+ if not processed:
240
+ await asyncio.sleep(0.01) # Small sleep if no request
241
+
242
+ def stop(self) -> None:
243
+ """Stop the server loop."""
244
+ self._running = False
245
+
246
+
247
+ def create_tool_proxy_pair() -> tuple[ToolProxyClient, ToolProxyServer]:
248
+ """Create a matched client/server proxy pair.
249
+
250
+ Returns:
251
+ Tuple of (client for sandbox, server for main process)
252
+ """
253
+ request_queue: Queue = multiprocessing.Queue() # type: ignore[type-arg]
254
+ response_queue: Queue = multiprocessing.Queue() # type: ignore[type-arg]
255
+
256
+ client = ToolProxyClient(request_queue, response_queue)
257
+ server = ToolProxyServer(request_queue, response_queue)
258
+
259
+ return client, server
@@ -0,0 +1,223 @@
1
+ """Virtual filesystem for tool discovery.
2
+
3
+ Provides ls() and read() functions that allow the LLM to browse
4
+ available tools without loading all definitions upfront.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ @dataclass
14
+ class ToolCategory:
15
+ """A category of related tools.
16
+
17
+ Attributes:
18
+ name: Category name (e.g., "devices", "home-assistant")
19
+ description: Category description
20
+ tools: Dict of tool filename -> tool content/definition
21
+ """
22
+ name: str
23
+ description: str = ""
24
+ tools: dict[str, str] = field(default_factory=dict)
25
+
26
+
27
+ class ToolRegistry:
28
+ """Registry of tool categories and definitions.
29
+
30
+ Manages tool registration and provides lookup for the virtual filesystem.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self._categories: dict[str, ToolCategory] = {}
35
+
36
+ def register_category(
37
+ self,
38
+ name: str,
39
+ description: str = "",
40
+ tools: dict[str, str] | None = None,
41
+ ) -> ToolCategory:
42
+ """Register a new tool category.
43
+
44
+ Args:
45
+ name: Category name
46
+ description: Category description
47
+ tools: Dict of tool filename -> tool content
48
+
49
+ Returns:
50
+ The created ToolCategory
51
+ """
52
+ category = ToolCategory(
53
+ name=name,
54
+ description=description,
55
+ tools=tools or {},
56
+ )
57
+ self._categories[name] = category
58
+ return category
59
+
60
+ def get_category(self, name: str) -> ToolCategory | None:
61
+ """Get a category by name."""
62
+ return self._categories.get(name)
63
+
64
+ def get_tool_definition(self, category: str, tool_name: str) -> str | None:
65
+ """Get a specific tool's definition content.
66
+
67
+ Args:
68
+ category: Category name
69
+ tool_name: Tool filename (e.g., "registry.py")
70
+
71
+ Returns:
72
+ Tool content/definition or None if not found
73
+ """
74
+ cat = self._categories.get(category)
75
+ if cat:
76
+ return cat.tools.get(tool_name)
77
+ return None
78
+
79
+ def list_categories(self) -> list[str]:
80
+ """List all category names."""
81
+ return list(self._categories.keys())
82
+
83
+ def list_tools(self, category: str) -> list[str]:
84
+ """List tool filenames in a category.
85
+
86
+ Args:
87
+ category: Category name
88
+
89
+ Returns:
90
+ List of tool filenames (e.g., ["registry.py", "control.py"])
91
+ """
92
+ cat = self._categories.get(category)
93
+ if cat:
94
+ return list(cat.tools.keys())
95
+ return []
96
+
97
+
98
+ class VirtualFilesystem:
99
+ """Virtual filesystem for tool discovery.
100
+
101
+ Provides ls() and read() functions that can be injected into the sandbox.
102
+
103
+ Directory structure:
104
+ tools/
105
+ ├── __index__.md
106
+ ├── devices/
107
+ │ ├── __index__.md
108
+ │ ├── registry.py
109
+ │ └── control.py
110
+ └── sensors/
111
+ ├── __index__.md
112
+ └── temperature.py
113
+ """
114
+
115
+ def __init__(self, registry: ToolRegistry) -> None:
116
+ self._registry = registry
117
+
118
+ def ls(self, path: str) -> list[str]:
119
+ """List directory contents.
120
+
121
+ Args:
122
+ path: Path like "tools/" or "tools/devices/"
123
+
124
+ Returns:
125
+ List of entries (directories end with /, files don't)
126
+ """
127
+ path = path.rstrip("/")
128
+
129
+ if path == "tools" or path == "":
130
+ # Root: list categories
131
+ entries = ["__index__.md"]
132
+ for cat in self._registry.list_categories():
133
+ entries.append(f"{cat}/")
134
+ return sorted(entries)
135
+
136
+ if path.startswith("tools/"):
137
+ category = path[6:] # Remove "tools/"
138
+ if category in self._registry.list_categories():
139
+ # Category: list tools
140
+ entries = ["__index__.md"]
141
+ for tool in self._registry.list_tools(category):
142
+ entries.append(tool)
143
+ return sorted(entries)
144
+
145
+ return []
146
+
147
+ def read(self, path: str) -> str:
148
+ """Read file contents.
149
+
150
+ Args:
151
+ path: Path like "tools/__index__.md" or "tools/devices/registry.py"
152
+
153
+ Returns:
154
+ File contents or error message
155
+ """
156
+ path = path.lstrip("/")
157
+
158
+ # Root index
159
+ if path == "tools/__index__.md":
160
+ return self._generate_root_index()
161
+
162
+ # Category index
163
+ if path.startswith("tools/") and path.endswith("/__index__.md"):
164
+ category = path[6:-13] # Remove "tools/" and "/__index__.md"
165
+ cat = self._registry.get_category(category)
166
+ if cat:
167
+ return self._generate_category_index(cat)
168
+ return f"Error: Category '{category}' not found"
169
+
170
+ # Tool file
171
+ if path.startswith("tools/") and path.endswith(".py"):
172
+ parts = path[6:].split("/") # Remove "tools/"
173
+ if len(parts) == 2:
174
+ category, tool_name = parts
175
+ content = self._registry.get_tool_definition(category, tool_name)
176
+ if content is not None:
177
+ return content
178
+ return f"Error: Tool not found at '{path}'"
179
+
180
+ return f"Error: File not found at '{path}'"
181
+
182
+ def _generate_root_index(self) -> str:
183
+ """Generate tools/__index__.md content."""
184
+ lines = [
185
+ "# Available Tool Categories",
186
+ "",
187
+ "Browse these directories to find tools:",
188
+ "",
189
+ "| Category | Description | Tools |",
190
+ "|----------|-------------|-------|",
191
+ ]
192
+ for cat_name in sorted(self._registry.list_categories()):
193
+ cat = self._registry.get_category(cat_name)
194
+ if cat:
195
+ tool_count = len(cat.tools)
196
+ lines.append(
197
+ f"| `{cat_name}/` | {cat.description} | {tool_count} tools |"
198
+ )
199
+ lines.extend([
200
+ "",
201
+ 'Use `ls("tools/<category>/")` to see tools in a category.',
202
+ 'Use `read("tools/<category>/<tool>.py")` to see tool details.',
203
+ ])
204
+ return "\n".join(lines)
205
+
206
+ def _generate_category_index(self, category: ToolCategory) -> str:
207
+ """Generate __index__.md content for a category."""
208
+ lines = [f"# {category.name}", "", category.description, "", "## Tools", ""]
209
+ for tool_name in sorted(category.tools.keys()):
210
+ lines.append(f"- `{tool_name}`")
211
+ return "\n".join(lines)
212
+
213
+ def get_sandbox_globals(self) -> dict[str, Any]:
214
+ """Get globals dict to inject into sandbox.
215
+
216
+ Returns:
217
+ Dict with ls and read functions bound to this filesystem
218
+ """
219
+ return {
220
+ "ls": self.ls,
221
+ "read": self.read,
222
+ }
223
+
@@ -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'
@@ -1,10 +1,14 @@
1
1
  voxagent/__init__.py,sha256=YMYC95iwWXK26hicGYmd2erNOInrYtohwUVOuNmpTCs,3927
2
- voxagent/_version.py,sha256=dci_7AJwV3XNR3Hhs5EbgyENsGHyAr6FK5tHu7zs4M4,87
2
+ voxagent/_version.py,sha256=hneVJodkPueiXrtmysAsTToF1j7vXo9HPxsiIEn-vlc,87
3
3
  voxagent/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
4
  voxagent/agent/__init__.py,sha256=eASoU7Zhvw8BtJ-iUqVN06S4fMLkHwDgUZbHeH2AUOM,755
5
5
  voxagent/agent/abort.py,sha256=2Wnnxq8Dcn7wQkKPHrba2o0OeOdzF4NNsl-usgE4CJw,5191
6
6
  voxagent/agent/core.py,sha256=zXDUubx6oWQCj1V997s2zJ0Xj0XCaWIPZNinTNMz2zY,30581
7
- voxagent/code/__init__.py,sha256=6-i-FjYxwNay7XvWt9kwwg2nk3wPNzRp54AtEBdQ13U,237
7
+ voxagent/code/__init__.py,sha256=MzbrYReislAB-lCZEZn_lBEPGjYZyPK-c9RFCq1nSm0,1379
8
+ voxagent/code/agent.py,sha256=ZHN4OYVVD1yI75QDjvqYOrexgh1-srH8qiBfvJhV-Pk,8884
9
+ voxagent/code/sandbox.py,sha256=LP2cwXchDk6mtiYGRb_RmkGNoyPv5OEKQC2h4M89dC0,11669
10
+ voxagent/code/tool_proxy.py,sha256=wZvRqXoz2SfYTHLpe8tIkpJL6b8fmlU96DSGeYw-HfM,8091
11
+ voxagent/code/virtual_fs.py,sha256=pKZXddcshtZqoLLZ6sL2tJ7f0JbPQF_TpdDmPbpJ_qg,7088
8
12
  voxagent/mcp/__init__.py,sha256=_3Rsn7nIuivdWLv0MzpyjRGsPuCgr4LrXCge6FCb3nE,470
9
13
  voxagent/mcp/manager.py,sha256=sECOhw-f6HB6NV-mBqcgJzsEt28acdQI_O2cT-41Rxw,6606
10
14
  voxagent/mcp/tool.py,sha256=YQQqXNcanDDr5tkL1Z5OjNsDI5dWMEzm_SlJ4pOcUpk,5147
@@ -48,6 +52,6 @@ voxagent/tools/registry.py,sha256=MNJzgcmKT0AoMWIky9TJY4WVhzn5dkmjIHsUiZ3mv3U,25
48
52
  voxagent/types/__init__.py,sha256=3VunuprKKEpOR9Cg-UITHJXds_xQ-tfqQb4S7wD3nP4,933
49
53
  voxagent/types/messages.py,sha256=c6hNi9w6C8gbFoFm5fFge35vwJGywaoR_OiPQprfyVs,3494
50
54
  voxagent/types/run.py,sha256=4vYq0pCqH7_7SWbMb1SplWj4TLiE3DELDYMi0HefFmo,5071
51
- voxagent-0.1.1.dist-info/METADATA,sha256=ybDiX1x6IsHgK-mxSOHQhKviowAM0aNyksgcBkQ2BzU,5610
52
- voxagent-0.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
53
- voxagent-0.1.1.dist-info/RECORD,,
55
+ voxagent-0.2.1.dist-info/METADATA,sha256=aaQR2qswaeyZIGw_5DE_OoUSn0GO2-MFGxt33y9-Nmc,5685
56
+ voxagent-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
57
+ voxagent-0.2.1.dist-info/RECORD,,