voxagent 0.2.3__tar.gz → 0.2.5__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.
- {voxagent-0.2.3 → voxagent-0.2.5}/PKG-INFO +1 -1
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/_version.py +1 -1
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/agent/core.py +90 -20
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/code/__init__.py +3 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/code/agent.py +72 -12
- voxagent-0.2.5/src/voxagent/code/query.py +179 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/code/sandbox.py +144 -4
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/code/virtual_fs.py +7 -17
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/chatgpt.py +16 -7
- {voxagent-0.2.3 → voxagent-0.2.5}/.gitignore +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/README.md +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/examples/README.md +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/pyproject.toml +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/agent/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/agent/abort.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/code/tool_proxy.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/mcp/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/mcp/manager.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/mcp/tool.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/anthropic.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/augment.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/auth.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/base.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/claudecode.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/cli_base.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/codex.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/failover.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/google.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/groq.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/ollama.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/openai.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/providers/registry.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/py.typed +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/security/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/security/events.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/security/filter.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/security/registry.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/session/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/session/compaction.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/session/lock.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/session/model.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/session/storage.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/streaming/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/streaming/emitter.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/streaming/events.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/subagent/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/subagent/context.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/subagent/definition.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/tools/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/tools/context.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/tools/decorator.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/tools/definition.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/tools/executor.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/tools/policy.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/tools/registry.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/types/__init__.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/types/messages.py +0 -0
- {voxagent-0.2.3 → voxagent-0.2.5}/src/voxagent/types/run.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voxagent
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
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
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "0.2.
|
|
1
|
+
__version__ = "0.2.5"
|
|
2
2
|
__version_info__ = tuple(int(x) for x in __version__.split("."))
|
|
@@ -125,6 +125,68 @@ class Agent(Generic[DepsT, OutputT]):
|
|
|
125
125
|
# Toolsets (MCP servers, etc.) - store for later
|
|
126
126
|
self._toolsets = toolsets or []
|
|
127
127
|
|
|
128
|
+
# MCP connection caching (persistent across run() calls)
|
|
129
|
+
self._mcp_manager: MCPServerManager | None = None
|
|
130
|
+
self._mcp_tools: list[ToolDefinition] = []
|
|
131
|
+
self._mcp_connected: bool = False
|
|
132
|
+
|
|
133
|
+
# -------------------------------------------------------------------------
|
|
134
|
+
# MCP Connection Management
|
|
135
|
+
# -------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
async def connect_mcp(self) -> list[ToolDefinition]:
|
|
138
|
+
"""Connect to MCP servers and cache the connection.
|
|
139
|
+
|
|
140
|
+
This method connects to all MCP servers in toolsets and caches the
|
|
141
|
+
connection for reuse across multiple run() calls. Call this during
|
|
142
|
+
initialization/warmup to avoid connection overhead on first message.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of ToolDefinition objects from connected MCP servers.
|
|
146
|
+
"""
|
|
147
|
+
if self._mcp_connected:
|
|
148
|
+
return self._mcp_tools
|
|
149
|
+
|
|
150
|
+
if self._toolsets:
|
|
151
|
+
self._mcp_manager = MCPServerManager()
|
|
152
|
+
await self._mcp_manager.add_servers(self._toolsets)
|
|
153
|
+
self._mcp_tools = await self._mcp_manager.connect_all()
|
|
154
|
+
self._mcp_connected = True
|
|
155
|
+
|
|
156
|
+
return self._mcp_tools
|
|
157
|
+
|
|
158
|
+
async def disconnect_mcp(self) -> None:
|
|
159
|
+
"""Disconnect from MCP servers.
|
|
160
|
+
|
|
161
|
+
Call this when the agent is no longer needed to clean up MCP
|
|
162
|
+
server connections. This is called automatically when using
|
|
163
|
+
the agent as an async context manager.
|
|
164
|
+
"""
|
|
165
|
+
if self._mcp_manager and self._mcp_connected:
|
|
166
|
+
await self._mcp_manager.disconnect_all()
|
|
167
|
+
self._mcp_manager = None
|
|
168
|
+
self._mcp_connected = False
|
|
169
|
+
self._mcp_tools = []
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def mcp_connected(self) -> bool:
|
|
173
|
+
"""Check if MCP servers are currently connected."""
|
|
174
|
+
return self._mcp_connected
|
|
175
|
+
|
|
176
|
+
async def __aenter__(self) -> "Agent[DepsT, OutputT]":
|
|
177
|
+
"""Enter async context manager - connect MCP servers."""
|
|
178
|
+
await self.connect_mcp()
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
async def __aexit__(
|
|
182
|
+
self,
|
|
183
|
+
exc_type: type[BaseException] | None,
|
|
184
|
+
exc_val: BaseException | None,
|
|
185
|
+
exc_tb: Any,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Exit async context manager - disconnect MCP servers."""
|
|
188
|
+
await self.disconnect_mcp()
|
|
189
|
+
|
|
128
190
|
@staticmethod
|
|
129
191
|
def _parse_model_string(model_string: str) -> ModelConfig:
|
|
130
192
|
"""Parse 'provider:model' string into ModelConfig.
|
|
@@ -495,19 +557,22 @@ class Agent(Generic[DepsT, OutputT]):
|
|
|
495
557
|
timeout_handler: TimeoutHandler | None = None
|
|
496
558
|
timed_out = False
|
|
497
559
|
error_message: str | None = None
|
|
498
|
-
|
|
560
|
+
# Track if we connected MCP in this run (for cleanup)
|
|
561
|
+
mcp_connected_in_this_run = False
|
|
499
562
|
|
|
500
563
|
if timeout_ms:
|
|
501
564
|
timeout_handler = TimeoutHandler(timeout_ms)
|
|
502
565
|
await timeout_handler.start(abort_controller)
|
|
503
566
|
|
|
504
567
|
try:
|
|
505
|
-
#
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
await
|
|
510
|
-
|
|
568
|
+
# Use cached MCP connection if available, otherwise connect
|
|
569
|
+
if self._mcp_connected:
|
|
570
|
+
mcp_tools = self._mcp_tools
|
|
571
|
+
elif self._toolsets:
|
|
572
|
+
mcp_tools = await self.connect_mcp()
|
|
573
|
+
mcp_connected_in_this_run = True
|
|
574
|
+
else:
|
|
575
|
+
mcp_tools = []
|
|
511
576
|
|
|
512
577
|
# Get all tools (native + MCP)
|
|
513
578
|
all_tools = self._get_all_tools(mcp_tools)
|
|
@@ -652,9 +717,10 @@ class Agent(Generic[DepsT, OutputT]):
|
|
|
652
717
|
)
|
|
653
718
|
|
|
654
719
|
finally:
|
|
655
|
-
#
|
|
656
|
-
if
|
|
657
|
-
|
|
720
|
+
# Only disconnect MCP servers if we connected them in this run
|
|
721
|
+
# (not if using cached connection from connect_mcp())
|
|
722
|
+
if mcp_connected_in_this_run and not self._mcp_connected:
|
|
723
|
+
await self.disconnect_mcp()
|
|
658
724
|
if timeout_handler:
|
|
659
725
|
timeout_handler.cancel()
|
|
660
726
|
abort_controller.cleanup()
|
|
@@ -685,19 +751,22 @@ class Agent(Generic[DepsT, OutputT]):
|
|
|
685
751
|
abort_controller = AbortController()
|
|
686
752
|
timeout_handler: TimeoutHandler | None = None
|
|
687
753
|
timed_out = False
|
|
688
|
-
|
|
754
|
+
# Track if we connected MCP in this run (for cleanup)
|
|
755
|
+
mcp_connected_in_this_run = False
|
|
689
756
|
|
|
690
757
|
if timeout_ms:
|
|
691
758
|
timeout_handler = TimeoutHandler(timeout_ms)
|
|
692
759
|
await timeout_handler.start(abort_controller)
|
|
693
760
|
|
|
694
761
|
try:
|
|
695
|
-
#
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
await
|
|
700
|
-
|
|
762
|
+
# Use cached MCP connection if available, otherwise connect
|
|
763
|
+
if self._mcp_connected:
|
|
764
|
+
mcp_tools = self._mcp_tools
|
|
765
|
+
elif self._toolsets:
|
|
766
|
+
mcp_tools = await self.connect_mcp()
|
|
767
|
+
mcp_connected_in_this_run = True
|
|
768
|
+
else:
|
|
769
|
+
mcp_tools = []
|
|
701
770
|
|
|
702
771
|
# Get all tools (native + MCP)
|
|
703
772
|
all_tools = self._get_all_tools(mcp_tools)
|
|
@@ -838,9 +907,10 @@ class Agent(Generic[DepsT, OutputT]):
|
|
|
838
907
|
)
|
|
839
908
|
|
|
840
909
|
finally:
|
|
841
|
-
#
|
|
842
|
-
if
|
|
843
|
-
|
|
910
|
+
# Only disconnect MCP servers if we connected them in this run
|
|
911
|
+
# (not if using cached connection from connect_mcp())
|
|
912
|
+
if mcp_connected_in_this_run and not self._mcp_connected:
|
|
913
|
+
await self.disconnect_mcp()
|
|
844
914
|
if timeout_handler:
|
|
845
915
|
timeout_handler.cancel()
|
|
846
916
|
abort_controller.cleanup()
|
|
@@ -30,6 +30,7 @@ from voxagent.code.tool_proxy import (
|
|
|
30
30
|
ToolProxyServer,
|
|
31
31
|
create_tool_proxy_pair,
|
|
32
32
|
)
|
|
33
|
+
from voxagent.code.query import QueryResult
|
|
33
34
|
|
|
34
35
|
__all__ = [
|
|
35
36
|
# Sandbox
|
|
@@ -52,4 +53,6 @@ __all__ = [
|
|
|
52
53
|
"ToolProxyClient",
|
|
53
54
|
"ToolProxyServer",
|
|
54
55
|
"create_tool_proxy_pair",
|
|
56
|
+
# Query
|
|
57
|
+
"QueryResult",
|
|
55
58
|
]
|
|
@@ -7,9 +7,12 @@ that calls tools via the virtual filesystem.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import asyncio
|
|
11
|
+
import multiprocessing
|
|
10
12
|
from typing import TYPE_CHECKING, Any
|
|
11
13
|
|
|
12
14
|
from voxagent.code.sandbox import SubprocessSandbox, SandboxResult
|
|
15
|
+
from voxagent.code.tool_proxy import ToolProxyServer
|
|
13
16
|
from voxagent.code.virtual_fs import VirtualFilesystem, ToolRegistry
|
|
14
17
|
from voxagent.tools.definition import ToolDefinition
|
|
15
18
|
|
|
@@ -32,6 +35,21 @@ You have access to a single tool: `execute_code`. Use it to write Python code th
|
|
|
32
35
|
- `call_tool(category, tool_name, **kwargs)` - Call a tool with arguments
|
|
33
36
|
- `print(*args)` - Output results (captured and returned to you)
|
|
34
37
|
|
|
38
|
+
### Query Functions (for efficient data processing)
|
|
39
|
+
- `query(data)` - Wrap tool result in QueryResult for chaining
|
|
40
|
+
- `tree(path?)` - Show full tool structure at a glance
|
|
41
|
+
- `search(keyword)` - Find tools by name
|
|
42
|
+
|
|
43
|
+
### QueryResult Methods (chainable)
|
|
44
|
+
- `.filter(**patterns)` - Filter by regex patterns on fields
|
|
45
|
+
- `.map(fields)` - Extract specific fields
|
|
46
|
+
- `.reduce(by?, count?)` - Aggregate/group results
|
|
47
|
+
- `.first()` - Get first result
|
|
48
|
+
- `.last()` - Get last result
|
|
49
|
+
- `.each(fn)` - Apply function to each result
|
|
50
|
+
- `.sort(by, reverse?)` - Sort results by field
|
|
51
|
+
- `.unique(by)` - Remove duplicates by field
|
|
52
|
+
|
|
35
53
|
### Workflow
|
|
36
54
|
1. **Explore**: `print(ls("tools/"))` to see categories
|
|
37
55
|
2. **Learn**: `print(read("tools/<category>/<tool>.py"))` to see tool signatures
|
|
@@ -51,6 +69,19 @@ result = call_tool("devices", "registry.py", device_type="light")
|
|
|
51
69
|
print("Devices:", result)
|
|
52
70
|
```
|
|
53
71
|
|
|
72
|
+
### Efficient Device Control Example
|
|
73
|
+
```python
|
|
74
|
+
# Instead of 12+ calls, use 2:
|
|
75
|
+
devices = call_tool("devices", "list_devices")
|
|
76
|
+
device = query(devices).filter(name="balcony").first()
|
|
77
|
+
call_tool("control", "turn_on_device", **device)
|
|
78
|
+
|
|
79
|
+
# Find all living room lights and turn them off
|
|
80
|
+
devices = call_tool("devices", "list_devices")
|
|
81
|
+
living_lights = query(devices).filter(room="living", type="light")
|
|
82
|
+
living_lights.each(lambda d: call_tool("control", "turn_off_device", device_id=d["device_id"]))
|
|
83
|
+
```
|
|
84
|
+
|
|
54
85
|
### Rules
|
|
55
86
|
1. Always use `print()` to show results
|
|
56
87
|
2. Explore before assuming - use `ls()` and `read()` first
|
|
@@ -83,13 +114,13 @@ class CodeModeConfig:
|
|
|
83
114
|
|
|
84
115
|
class CodeModeExecutor:
|
|
85
116
|
"""Executes code for an agent in code mode.
|
|
86
|
-
|
|
117
|
+
|
|
87
118
|
This class:
|
|
88
119
|
1. Manages the sandbox and virtual filesystem
|
|
89
120
|
2. Provides the execute_code tool implementation
|
|
90
|
-
3. Routes tool calls from sandbox to real implementations
|
|
121
|
+
3. Routes tool calls from sandbox to real implementations via queue-based proxy
|
|
91
122
|
"""
|
|
92
|
-
|
|
123
|
+
|
|
93
124
|
def __init__(
|
|
94
125
|
self,
|
|
95
126
|
config: CodeModeConfig,
|
|
@@ -97,19 +128,28 @@ class CodeModeExecutor:
|
|
|
97
128
|
):
|
|
98
129
|
self.config = config
|
|
99
130
|
self.tool_registry = tool_registry
|
|
131
|
+
|
|
132
|
+
# Create tool proxy queues (queues are picklable, client/server objects are not)
|
|
133
|
+
self._tool_request_queue: multiprocessing.Queue[Any] = multiprocessing.Queue()
|
|
134
|
+
self._tool_response_queue: multiprocessing.Queue[Any] = multiprocessing.Queue()
|
|
135
|
+
|
|
136
|
+
# Create proxy server for main process
|
|
137
|
+
self._proxy_server = ToolProxyServer(
|
|
138
|
+
self._tool_request_queue,
|
|
139
|
+
self._tool_response_queue,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Create sandbox (queues passed during execute)
|
|
100
143
|
self.sandbox = SubprocessSandbox(
|
|
101
144
|
timeout_seconds=config.timeout_seconds,
|
|
102
145
|
memory_limit_mb=config.memory_limit_mb,
|
|
103
146
|
)
|
|
104
147
|
|
|
105
|
-
# Tool
|
|
148
|
+
# Tool implementations registry (kept for backward compatibility)
|
|
106
149
|
self._tool_implementations: dict[str, Any] = {}
|
|
107
150
|
|
|
108
|
-
# Create virtual filesystem
|
|
109
|
-
self.virtual_fs = VirtualFilesystem(
|
|
110
|
-
registry=tool_registry,
|
|
111
|
-
tool_caller=self.call_tool,
|
|
112
|
-
)
|
|
151
|
+
# Create virtual filesystem (no longer needs tool_caller)
|
|
152
|
+
self.virtual_fs = VirtualFilesystem(registry=tool_registry)
|
|
113
153
|
|
|
114
154
|
def register_tool_implementation(
|
|
115
155
|
self,
|
|
@@ -120,6 +160,8 @@ class CodeModeExecutor:
|
|
|
120
160
|
"""Register a real tool implementation for the proxy."""
|
|
121
161
|
key = f"{category}.{tool_name}"
|
|
122
162
|
self._tool_implementations[key] = implementation
|
|
163
|
+
# Also register with proxy server for queue-based communication
|
|
164
|
+
self._proxy_server.register_implementation(category, tool_name, implementation)
|
|
123
165
|
|
|
124
166
|
async def execute_code(self, code: str) -> str:
|
|
125
167
|
"""Execute Python code in the sandbox.
|
|
@@ -132,11 +174,29 @@ class CodeModeExecutor:
|
|
|
132
174
|
Returns:
|
|
133
175
|
Captured output or error message
|
|
134
176
|
"""
|
|
135
|
-
# Build globals with virtual filesystem functions (ls, read
|
|
177
|
+
# Build globals with virtual filesystem functions (ls, read)
|
|
136
178
|
globals_dict = self.virtual_fs.get_sandbox_globals()
|
|
137
179
|
|
|
138
|
-
#
|
|
139
|
-
|
|
180
|
+
# Start proxy server task to handle tool calls from subprocess
|
|
181
|
+
server_task = asyncio.create_task(
|
|
182
|
+
self._proxy_server.run_until_complete(timeout=self.config.timeout_seconds + 5)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# Execute in sandbox with queues for tool proxy
|
|
187
|
+
result = await self.sandbox.execute(
|
|
188
|
+
code,
|
|
189
|
+
globals_dict,
|
|
190
|
+
tool_request_queue=self._tool_request_queue,
|
|
191
|
+
tool_response_queue=self._tool_response_queue,
|
|
192
|
+
)
|
|
193
|
+
finally:
|
|
194
|
+
# Stop server
|
|
195
|
+
self._proxy_server.stop()
|
|
196
|
+
try:
|
|
197
|
+
await asyncio.wait_for(server_task, timeout=1.0)
|
|
198
|
+
except asyncio.TimeoutError:
|
|
199
|
+
pass
|
|
140
200
|
|
|
141
201
|
# Format output
|
|
142
202
|
if result.success:
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Query utilities for Code Mode sandbox.
|
|
2
|
+
|
|
3
|
+
Provides functional-style data pipelines for efficient tool result processing.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Callable, Iterator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueryResult:
|
|
13
|
+
"""Chainable wrapper for query results.
|
|
14
|
+
|
|
15
|
+
Enables functional-style data pipelines:
|
|
16
|
+
filter("tools/devices/list_devices", name="balcony").map(["device_id", "name"]).first()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, data: list[dict[str, Any]]) -> None:
|
|
20
|
+
"""Initialize with list of dicts."""
|
|
21
|
+
self._data = data if isinstance(data, list) else [data] if data else []
|
|
22
|
+
|
|
23
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
24
|
+
"""Allow iteration over results."""
|
|
25
|
+
return iter(self._data)
|
|
26
|
+
|
|
27
|
+
def __len__(self) -> int:
|
|
28
|
+
"""Return number of results."""
|
|
29
|
+
return len(self._data)
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
"""String representation."""
|
|
33
|
+
return f"QueryResult({len(self._data)} items)"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def data(self) -> list[dict[str, Any]]:
|
|
37
|
+
"""Get raw data."""
|
|
38
|
+
return self._data
|
|
39
|
+
|
|
40
|
+
def filter(self, **patterns: str) -> "QueryResult":
|
|
41
|
+
"""Filter results by regex patterns on field values.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
**patterns: Field name -> regex pattern pairs
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
New QueryResult with matching items
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
result.filter(name="balcony", room="living.*")
|
|
51
|
+
"""
|
|
52
|
+
if not patterns:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
filtered = []
|
|
56
|
+
for item in self._data:
|
|
57
|
+
match = True
|
|
58
|
+
for field, pattern in patterns.items():
|
|
59
|
+
value = str(item.get(field, ""))
|
|
60
|
+
if not re.search(pattern, value, re.IGNORECASE):
|
|
61
|
+
match = False
|
|
62
|
+
break
|
|
63
|
+
if match:
|
|
64
|
+
filtered.append(item)
|
|
65
|
+
|
|
66
|
+
return QueryResult(filtered)
|
|
67
|
+
|
|
68
|
+
def map(self, fields: list[str] | str) -> "QueryResult":
|
|
69
|
+
"""Extract specific fields from each result.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
fields: Field name or list of field names to extract
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
New QueryResult with only specified fields
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
result.map(["device_id", "name"])
|
|
79
|
+
result.map("device_id") # Returns list of values
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(fields, str):
|
|
82
|
+
# Single field - return list of values wrapped in dicts
|
|
83
|
+
return QueryResult([{fields: item.get(fields)} for item in self._data])
|
|
84
|
+
|
|
85
|
+
mapped = []
|
|
86
|
+
for item in self._data:
|
|
87
|
+
mapped.append({f: item.get(f) for f in fields if f in item})
|
|
88
|
+
return QueryResult(mapped)
|
|
89
|
+
|
|
90
|
+
def reduce(self, by: str | None = None, count: bool = False) -> dict[str, Any]:
|
|
91
|
+
"""Aggregate results.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
by: Field to group by (optional)
|
|
95
|
+
count: If True, return counts per group
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Aggregated dict
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
result.reduce(by="room", count=True) # {"living": 3, "bedroom": 2}
|
|
102
|
+
"""
|
|
103
|
+
if by is None:
|
|
104
|
+
if count:
|
|
105
|
+
return {"count": len(self._data)}
|
|
106
|
+
return {"items": self._data}
|
|
107
|
+
|
|
108
|
+
groups: dict[str, list[dict[str, Any]]] = {}
|
|
109
|
+
for item in self._data:
|
|
110
|
+
key = str(item.get(by, "unknown"))
|
|
111
|
+
if key not in groups:
|
|
112
|
+
groups[key] = []
|
|
113
|
+
groups[key].append(item)
|
|
114
|
+
|
|
115
|
+
if count:
|
|
116
|
+
return {k: len(v) for k, v in groups.items()}
|
|
117
|
+
return groups
|
|
118
|
+
|
|
119
|
+
def first(self) -> dict[str, Any] | None:
|
|
120
|
+
"""Get first result or None.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
First item or None if empty
|
|
124
|
+
"""
|
|
125
|
+
return self._data[0] if self._data else None
|
|
126
|
+
|
|
127
|
+
def last(self) -> dict[str, Any] | None:
|
|
128
|
+
"""Get last result or None."""
|
|
129
|
+
return self._data[-1] if self._data else None
|
|
130
|
+
|
|
131
|
+
def each(self, fn: Callable[[dict[str, Any]], Any]) -> list[Any]:
|
|
132
|
+
"""Apply function to each result.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
fn: Function to apply to each item
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of function results
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
result.each(lambda d: call_tool("control", "turn_on", **d))
|
|
142
|
+
"""
|
|
143
|
+
return [fn(item) for item in self._data]
|
|
144
|
+
|
|
145
|
+
def sort(self, by: str, reverse: bool = False) -> "QueryResult":
|
|
146
|
+
"""Sort results by field.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
by: Field name to sort by
|
|
150
|
+
reverse: If True, sort descending
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
New QueryResult with sorted items
|
|
154
|
+
"""
|
|
155
|
+
sorted_data = sorted(
|
|
156
|
+
self._data,
|
|
157
|
+
key=lambda x: str(x.get(by, "")),
|
|
158
|
+
reverse=reverse
|
|
159
|
+
)
|
|
160
|
+
return QueryResult(sorted_data)
|
|
161
|
+
|
|
162
|
+
def unique(self, by: str) -> "QueryResult":
|
|
163
|
+
"""Remove duplicates based on field value.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
by: Field to check for uniqueness
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
New QueryResult with unique items
|
|
170
|
+
"""
|
|
171
|
+
seen: set[str] = set()
|
|
172
|
+
unique_items = []
|
|
173
|
+
for item in self._data:
|
|
174
|
+
key = str(item.get(by, ""))
|
|
175
|
+
if key not in seen:
|
|
176
|
+
seen.add(key)
|
|
177
|
+
unique_items.append(item)
|
|
178
|
+
return QueryResult(unique_items)
|
|
179
|
+
|
|
@@ -91,6 +91,8 @@ def _execute_in_subprocess(
|
|
|
91
91
|
globals_dict: dict[str, Any],
|
|
92
92
|
result_queue: multiprocessing.Queue, # type: ignore[type-arg]
|
|
93
93
|
memory_limit_mb: int,
|
|
94
|
+
tool_request_queue: "multiprocessing.Queue[Any] | None" = None,
|
|
95
|
+
tool_response_queue: "multiprocessing.Queue[Any] | None" = None,
|
|
94
96
|
) -> None:
|
|
95
97
|
"""Subprocess entry point for sandboxed execution."""
|
|
96
98
|
# Import here to avoid loading in main process
|
|
@@ -257,6 +259,114 @@ def _execute_in_subprocess(
|
|
|
257
259
|
"_write_": lambda x: x,
|
|
258
260
|
**globals_dict,
|
|
259
261
|
}
|
|
262
|
+
|
|
263
|
+
# Create call_tool function using proxy if queues are provided
|
|
264
|
+
if tool_request_queue is not None and tool_response_queue is not None:
|
|
265
|
+
from voxagent.code.tool_proxy import ToolProxyClient
|
|
266
|
+
proxy_client = ToolProxyClient(tool_request_queue, tool_response_queue)
|
|
267
|
+
|
|
268
|
+
def call_tool(category: str, tool_name: str, **kwargs: Any) -> Any:
|
|
269
|
+
proxy = proxy_client.create_tool_proxy(category, tool_name)
|
|
270
|
+
return proxy(**kwargs)
|
|
271
|
+
|
|
272
|
+
exec_globals["call_tool"] = call_tool
|
|
273
|
+
|
|
274
|
+
# Add query functions for efficient data processing
|
|
275
|
+
from voxagent.code.query import QueryResult
|
|
276
|
+
import re as _re
|
|
277
|
+
|
|
278
|
+
def query(data: list | dict) -> QueryResult:
|
|
279
|
+
"""Wrap data in QueryResult for chaining."""
|
|
280
|
+
if isinstance(data, dict) and "devices" in data:
|
|
281
|
+
# Handle list_devices response format
|
|
282
|
+
return QueryResult(data["devices"])
|
|
283
|
+
if isinstance(data, dict) and "items" in data:
|
|
284
|
+
return QueryResult(data["items"])
|
|
285
|
+
if isinstance(data, list):
|
|
286
|
+
return QueryResult(data)
|
|
287
|
+
return QueryResult([data] if data else [])
|
|
288
|
+
|
|
289
|
+
def tree(path: str = "tools") -> str:
|
|
290
|
+
"""Show full tool structure with signatures.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
path: Starting path (default: "tools")
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Tree-formatted string showing all tools
|
|
297
|
+
"""
|
|
298
|
+
lines = []
|
|
299
|
+
path = path.rstrip("/")
|
|
300
|
+
|
|
301
|
+
# Get ls function from globals_dict
|
|
302
|
+
ls_func = globals_dict.get("ls")
|
|
303
|
+
if ls_func is None:
|
|
304
|
+
return "(ls function not available)"
|
|
305
|
+
|
|
306
|
+
# Get root entries
|
|
307
|
+
entries = ls_func(path)
|
|
308
|
+
if isinstance(entries, str):
|
|
309
|
+
# Handle error message
|
|
310
|
+
return entries
|
|
311
|
+
for entry in entries:
|
|
312
|
+
if entry == "__index__.md":
|
|
313
|
+
continue
|
|
314
|
+
if entry.endswith("/"):
|
|
315
|
+
# It's a category
|
|
316
|
+
cat_name = entry.rstrip("/")
|
|
317
|
+
lines.append(f"📁 {cat_name}/")
|
|
318
|
+
# Get tools in category
|
|
319
|
+
cat_entries = ls_func(f"{path}/{cat_name}")
|
|
320
|
+
if isinstance(cat_entries, list):
|
|
321
|
+
for tool in cat_entries:
|
|
322
|
+
if tool != "__index__.md":
|
|
323
|
+
lines.append(f" 📄 {tool}")
|
|
324
|
+
else:
|
|
325
|
+
lines.append(f"📄 {entry}")
|
|
326
|
+
|
|
327
|
+
return "\n".join(lines) if lines else "(empty)"
|
|
328
|
+
|
|
329
|
+
def search(query_str: str) -> list:
|
|
330
|
+
"""Search for tools by keyword.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
query_str: Search term (matches tool names)
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
List of matching tool paths
|
|
337
|
+
"""
|
|
338
|
+
results = []
|
|
339
|
+
pattern = _re.compile(query_str, _re.IGNORECASE)
|
|
340
|
+
|
|
341
|
+
# Get ls function from globals_dict
|
|
342
|
+
ls_func = globals_dict.get("ls")
|
|
343
|
+
if ls_func is None:
|
|
344
|
+
return []
|
|
345
|
+
|
|
346
|
+
# Search all categories
|
|
347
|
+
categories = ls_func("tools")
|
|
348
|
+
if isinstance(categories, str):
|
|
349
|
+
return []
|
|
350
|
+
for cat in categories:
|
|
351
|
+
if cat == "__index__.md" or not cat.endswith("/"):
|
|
352
|
+
continue
|
|
353
|
+
cat_name = cat.rstrip("/")
|
|
354
|
+
tools = ls_func(f"tools/{cat_name}")
|
|
355
|
+
if isinstance(tools, str):
|
|
356
|
+
continue
|
|
357
|
+
for tool in tools:
|
|
358
|
+
if tool == "__index__.md":
|
|
359
|
+
continue
|
|
360
|
+
if pattern.search(tool) or pattern.search(cat_name):
|
|
361
|
+
results.append(f"tools/{cat_name}/{tool}")
|
|
362
|
+
|
|
363
|
+
return results
|
|
364
|
+
|
|
365
|
+
exec_globals["query"] = query
|
|
366
|
+
exec_globals["tree"] = tree
|
|
367
|
+
exec_globals["search"] = search
|
|
368
|
+
exec_globals["QueryResult"] = QueryResult
|
|
369
|
+
|
|
260
370
|
exec(byte_code, exec_globals)
|
|
261
371
|
# Get the _print object that was created during execution and call it
|
|
262
372
|
_print_obj = exec_globals.get("_print")
|
|
@@ -298,18 +408,48 @@ class SubprocessSandbox(CodeSandbox):
|
|
|
298
408
|
self.tool_proxy_client = tool_proxy_client
|
|
299
409
|
|
|
300
410
|
async def execute(
|
|
301
|
-
self,
|
|
411
|
+
self,
|
|
412
|
+
code: str,
|
|
413
|
+
globals_dict: dict[str, Any] | None = None,
|
|
414
|
+
tool_request_queue: "multiprocessing.Queue[Any] | None" = None,
|
|
415
|
+
tool_response_queue: "multiprocessing.Queue[Any] | None" = None,
|
|
302
416
|
) -> SandboxResult:
|
|
303
|
-
"""Execute code in subprocess with RestrictedPython.
|
|
417
|
+
"""Execute code in subprocess with RestrictedPython.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
code: Python source code to execute
|
|
421
|
+
globals_dict: Optional globals to inject (e.g., ls, read functions)
|
|
422
|
+
tool_request_queue: Queue for tool call requests from subprocess
|
|
423
|
+
tool_response_queue: Queue for tool call responses to subprocess
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
SandboxResult with output or error
|
|
427
|
+
"""
|
|
428
|
+
import asyncio
|
|
429
|
+
|
|
304
430
|
start_time = time.monotonic()
|
|
305
431
|
|
|
306
432
|
result_queue: multiprocessing.Queue[SandboxResult] = multiprocessing.Queue()
|
|
307
433
|
process = multiprocessing.Process(
|
|
308
434
|
target=_execute_in_subprocess,
|
|
309
|
-
args=(
|
|
435
|
+
args=(
|
|
436
|
+
code,
|
|
437
|
+
globals_dict or {},
|
|
438
|
+
result_queue,
|
|
439
|
+
self.memory_limit_mb,
|
|
440
|
+
tool_request_queue,
|
|
441
|
+
tool_response_queue,
|
|
442
|
+
),
|
|
310
443
|
)
|
|
311
444
|
process.start()
|
|
312
|
-
|
|
445
|
+
|
|
446
|
+
# Use non-blocking wait to allow event loop to run proxy server
|
|
447
|
+
# Poll the process status instead of blocking on join()
|
|
448
|
+
poll_interval = 0.01 # 10ms
|
|
449
|
+
elapsed = 0.0
|
|
450
|
+
while process.is_alive() and elapsed < self.timeout_seconds:
|
|
451
|
+
await asyncio.sleep(poll_interval)
|
|
452
|
+
elapsed = time.monotonic() - start_time
|
|
313
453
|
|
|
314
454
|
execution_time_ms = (time.monotonic() - start_time) * 1000
|
|
315
455
|
|
|
@@ -95,14 +95,12 @@ class ToolRegistry:
|
|
|
95
95
|
return []
|
|
96
96
|
|
|
97
97
|
|
|
98
|
-
# Type alias for tool caller function
|
|
99
|
-
ToolCaller = Any # Callable[[str, str, ...], Any]
|
|
100
|
-
|
|
101
|
-
|
|
102
98
|
class VirtualFilesystem:
|
|
103
99
|
"""Virtual filesystem for tool discovery.
|
|
104
100
|
|
|
105
101
|
Provides ls() and read() functions that can be injected into the sandbox.
|
|
102
|
+
The call_tool function is now injected directly by the sandbox using
|
|
103
|
+
the tool proxy pattern for reliable cross-process communication.
|
|
106
104
|
|
|
107
105
|
Directory structure:
|
|
108
106
|
tools/
|
|
@@ -116,13 +114,8 @@ class VirtualFilesystem:
|
|
|
116
114
|
└── temperature.py
|
|
117
115
|
"""
|
|
118
116
|
|
|
119
|
-
def __init__(
|
|
120
|
-
self,
|
|
121
|
-
registry: ToolRegistry,
|
|
122
|
-
tool_caller: ToolCaller | None = None,
|
|
123
|
-
) -> None:
|
|
117
|
+
def __init__(self, registry: ToolRegistry) -> None:
|
|
124
118
|
self._registry = registry
|
|
125
|
-
self._tool_caller = tool_caller
|
|
126
119
|
|
|
127
120
|
def ls(self, path: str) -> list[str]:
|
|
128
121
|
"""List directory contents.
|
|
@@ -223,15 +216,12 @@ class VirtualFilesystem:
|
|
|
223
216
|
"""Get globals dict to inject into sandbox.
|
|
224
217
|
|
|
225
218
|
Returns:
|
|
226
|
-
Dict with ls
|
|
219
|
+
Dict with ls and read functions bound to this filesystem.
|
|
220
|
+
Note: call_tool is now injected directly by the sandbox using
|
|
221
|
+
the tool proxy pattern for reliable cross-process communication.
|
|
227
222
|
"""
|
|
228
|
-
|
|
223
|
+
return {
|
|
229
224
|
"ls": self.ls,
|
|
230
225
|
"read": self.read,
|
|
231
226
|
}
|
|
232
227
|
|
|
233
|
-
if self._tool_caller is not None:
|
|
234
|
-
globals_dict["call_tool"] = self._tool_caller
|
|
235
|
-
|
|
236
|
-
return globals_dict
|
|
237
|
-
|
|
@@ -206,7 +206,7 @@ class ChatGPTProvider(BaseProvider):
|
|
|
206
206
|
input_msgs.append({
|
|
207
207
|
"type": "message",
|
|
208
208
|
"role": "assistant",
|
|
209
|
-
"content": [{"type": "
|
|
209
|
+
"content": [{"type": "output_text", "text": msg.content}],
|
|
210
210
|
})
|
|
211
211
|
# Add function_call items for each tool call
|
|
212
212
|
for tc in msg.tool_calls:
|
|
@@ -242,25 +242,28 @@ class ChatGPTProvider(BaseProvider):
|
|
|
242
242
|
"output": block.get("content", ""),
|
|
243
243
|
})
|
|
244
244
|
elif hasattr(block, "text"):
|
|
245
|
-
# TextBlock
|
|
245
|
+
# TextBlock - use output_text for assistant, input_text for user
|
|
246
|
+
content_type = "output_text" if msg.role == "assistant" else "input_text"
|
|
246
247
|
input_msgs.append({
|
|
247
248
|
"type": "message",
|
|
248
249
|
"role": "user" if msg.role == "user" else "assistant",
|
|
249
|
-
"content": [{"type":
|
|
250
|
+
"content": [{"type": content_type, "text": block.text}],
|
|
250
251
|
})
|
|
251
252
|
elif isinstance(block, dict) and "text" in block:
|
|
253
|
+
content_type = "output_text" if msg.role == "assistant" else "input_text"
|
|
252
254
|
input_msgs.append({
|
|
253
255
|
"type": "message",
|
|
254
256
|
"role": "user" if msg.role == "user" else "assistant",
|
|
255
|
-
"content": [{"type":
|
|
257
|
+
"content": [{"type": content_type, "text": block["text"]}],
|
|
256
258
|
})
|
|
257
259
|
elif isinstance(msg.content, str):
|
|
258
|
-
# Simple string content
|
|
260
|
+
# Simple string content - use output_text for assistant, input_text for user
|
|
259
261
|
role = "user" if msg.role == "user" else "assistant"
|
|
262
|
+
content_type = "output_text" if msg.role == "assistant" else "input_text"
|
|
260
263
|
input_msgs.append({
|
|
261
264
|
"type": "message",
|
|
262
265
|
"role": role,
|
|
263
|
-
"content": [{"type":
|
|
266
|
+
"content": [{"type": content_type, "text": msg.content}],
|
|
264
267
|
})
|
|
265
268
|
|
|
266
269
|
return input_msgs
|
|
@@ -335,7 +338,13 @@ class ChatGPTProvider(BaseProvider):
|
|
|
335
338
|
if response.status_code == 401:
|
|
336
339
|
yield ErrorChunk(error="Authentication failed - token may be expired")
|
|
337
340
|
return
|
|
338
|
-
response.
|
|
341
|
+
if response.status_code >= 400:
|
|
342
|
+
# Read error body before raising
|
|
343
|
+
error_body = await response.aread()
|
|
344
|
+
error_text = error_body.decode("utf-8", errors="replace")
|
|
345
|
+
logger.error("ChatGPT API error %d: %s", response.status_code, error_text)
|
|
346
|
+
yield ErrorChunk(error=f"HTTP {response.status_code}: {error_text[:500]}")
|
|
347
|
+
return
|
|
339
348
|
|
|
340
349
|
async for line in response.aiter_lines():
|
|
341
350
|
if abort_signal and abort_signal.aborted:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|