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 +1 -1
- voxagent/code/__init__.py +46 -0
- voxagent/code/agent.py +275 -0
- voxagent/code/sandbox.py +335 -0
- voxagent/code/tool_proxy.py +259 -0
- voxagent/code/virtual_fs.py +223 -0
- {voxagent-0.1.1.dist-info → voxagent-0.2.1.dist-info}/METADATA +3 -1
- {voxagent-0.1.1.dist-info → voxagent-0.2.1.dist-info}/RECORD +9 -5
- {voxagent-0.1.1.dist-info → voxagent-0.2.1.dist-info}/WHEEL +0 -0
voxagent/_version.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "0.
|
|
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
|
+
|
voxagent/code/sandbox.py
ADDED
|
@@ -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.
|
|
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=
|
|
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=
|
|
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.
|
|
52
|
-
voxagent-0.
|
|
53
|
-
voxagent-0.
|
|
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,,
|
|
File without changes
|