voxagent 0.2.3__py3-none-any.whl → 0.2.5__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.2.3"
1
+ __version__ = "0.2.5"
2
2
  __version_info__ = tuple(int(x) for x in __version__.split("."))
voxagent/agent/core.py CHANGED
@@ -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
- mcp_manager: MCPServerManager | None = None
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
- # Connect to MCP servers if any
506
- mcp_tools: list[ToolDefinition] = []
507
- if self._toolsets:
508
- mcp_manager = MCPServerManager()
509
- await mcp_manager.add_servers(self._toolsets)
510
- mcp_tools = await mcp_manager.connect_all()
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
- # Disconnect MCP servers
656
- if mcp_manager:
657
- await mcp_manager.disconnect_all()
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
- mcp_manager: MCPServerManager | None = None
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
- # Connect to MCP servers if any
696
- mcp_tools: list[ToolDefinition] = []
697
- if self._toolsets:
698
- mcp_manager = MCPServerManager()
699
- await mcp_manager.add_servers(self._toolsets)
700
- mcp_tools = await mcp_manager.connect_all()
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
- # Disconnect MCP servers
842
- if mcp_manager:
843
- await mcp_manager.disconnect_all()
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()
voxagent/code/__init__.py CHANGED
@@ -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
  ]
voxagent/code/agent.py CHANGED
@@ -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 proxy for routing calls
148
+ # Tool implementations registry (kept for backward compatibility)
106
149
  self._tool_implementations: dict[str, Any] = {}
107
150
 
108
- # Create virtual filesystem with call_tool support
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, call_tool)
177
+ # Build globals with virtual filesystem functions (ls, read)
136
178
  globals_dict = self.virtual_fs.get_sandbox_globals()
137
179
 
138
- # Execute in sandbox
139
- result = await self.sandbox.execute(code, globals_dict)
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:
voxagent/code/query.py ADDED
@@ -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
+
voxagent/code/sandbox.py CHANGED
@@ -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, code: str, globals_dict: dict[str, Any] | None = None
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=(code, globals_dict or {}, result_queue, self.memory_limit_mb),
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
- process.join(timeout=self.timeout_seconds)
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, read, and call_tool functions bound to this filesystem
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
- globals_dict: dict[str, Any] = {
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": "input_text", "text": msg.content}],
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": "input_text", "text": block.text}],
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": "input_text", "text": block["text"]}],
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": "input_text", "text": msg.content}],
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.raise_for_status()
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voxagent
3
- Version: 0.2.3
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,14 +1,15 @@
1
1
  voxagent/__init__.py,sha256=YMYC95iwWXK26hicGYmd2erNOInrYtohwUVOuNmpTCs,3927
2
- voxagent/_version.py,sha256=avoaFz2ddynhpdQn-udgPxrhqWOcXWc2liIjyh_vgkI,87
2
+ voxagent/_version.py,sha256=BQIRef51oDZ39-xgLzB-7zzZTwre-GSkFQ2C7tG_vg4,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
- voxagent/agent/core.py,sha256=zXDUubx6oWQCj1V997s2zJ0Xj0XCaWIPZNinTNMz2zY,30581
7
- voxagent/code/__init__.py,sha256=MzbrYReislAB-lCZEZn_lBEPGjYZyPK-c9RFCq1nSm0,1379
8
- voxagent/code/agent.py,sha256=fVaOlJNvnHJedPCCW5Rmm4_-YRvgcYpaLicXA-8HWxU,9634
9
- voxagent/code/sandbox.py,sha256=LP2cwXchDk6mtiYGRb_RmkGNoyPv5OEKQC2h4M89dC0,11669
6
+ voxagent/agent/core.py,sha256=ddjDoWcDqiacRtNzef5wORR2f5uHqL1Y1gOmzoeXRFY,33339
7
+ voxagent/code/__init__.py,sha256=WEmr8IcpVwM8eykSa4XDwPAwl5QmtzlygOLuzVA8JzE,1454
8
+ voxagent/code/agent.py,sha256=NbdhT1Zx99N76YzUqL1A49wmYViFuOp4l0zIoftGpIM,12128
9
+ voxagent/code/query.py,sha256=qqRxT9_4gJ28lYXY6Ygdzio8eAvWasuswVmVy0cNMjc,5462
10
+ voxagent/code/sandbox.py,sha256=sJvKL-qrfzXZDQTwDMPUZrtGCb_hznIiomTNb2wG7UM,16859
10
11
  voxagent/code/tool_proxy.py,sha256=wZvRqXoz2SfYTHLpe8tIkpJL6b8fmlU96DSGeYw-HfM,8091
11
- voxagent/code/virtual_fs.py,sha256=wgkH7voirgDEeD2xg0yaOxU08qWwF-iqXEWy94n0I5U,7435
12
+ voxagent/code/virtual_fs.py,sha256=L4Z_Kmoalwj4lJDhRxQOTOblC7Zrx8lv7slvHeHRILM,7362
12
13
  voxagent/mcp/__init__.py,sha256=_3Rsn7nIuivdWLv0MzpyjRGsPuCgr4LrXCge6FCb3nE,470
13
14
  voxagent/mcp/manager.py,sha256=sECOhw-f6HB6NV-mBqcgJzsEt28acdQI_O2cT-41Rxw,6606
14
15
  voxagent/mcp/tool.py,sha256=YQQqXNcanDDr5tkL1Z5OjNsDI5dWMEzm_SlJ4pOcUpk,5147
@@ -17,7 +18,7 @@ voxagent/providers/anthropic.py,sha256=A9aJUBc2nwqFdtvh5CyHp0GSQR82DaxwJQsgA79cf
17
18
  voxagent/providers/augment.py,sha256=K8ORZsOh88FBh4d7pYZUgMQHtQXnhJizpjwGkbWf53Y,8623
18
19
  voxagent/providers/auth.py,sha256=X3yDSHzj7gP-XxiYVpTSpDZs608gxnd-0UYX5aNNsEw,3650
19
20
  voxagent/providers/base.py,sha256=f_tPz4LFWX2nU9q1aa__Z3UpT35VpQnDVr40Z3VM6uk,6933
20
- voxagent/providers/chatgpt.py,sha256=DBjoc8UTONxwz2BGhsPVevSJBlLduai4gjzt62_lZrE,15620
21
+ voxagent/providers/chatgpt.py,sha256=YW7VVXiPyqmeWKwbXraRfKakYCdb0tlTfqB6RqRK0hY,16445
21
22
  voxagent/providers/claudecode.py,sha256=4pUnWO9IJlXnagnqu3bl6SIDdyjpDohjOwJqRe96g6U,4331
22
23
  voxagent/providers/cli_base.py,sha256=0N4psjsH9ekgfq6ynspPQ48iB5BmN6qiOSDPMHjJdBA,7554
23
24
  voxagent/providers/codex.py,sha256=NUtCH1fqf1OTI2JEOlNClofm6spWq4ZvlaPaHbyotyE,5397
@@ -52,6 +53,6 @@ voxagent/tools/registry.py,sha256=MNJzgcmKT0AoMWIky9TJY4WVhzn5dkmjIHsUiZ3mv3U,25
52
53
  voxagent/types/__init__.py,sha256=3VunuprKKEpOR9Cg-UITHJXds_xQ-tfqQb4S7wD3nP4,933
53
54
  voxagent/types/messages.py,sha256=c6hNi9w6C8gbFoFm5fFge35vwJGywaoR_OiPQprfyVs,3494
54
55
  voxagent/types/run.py,sha256=4vYq0pCqH7_7SWbMb1SplWj4TLiE3DELDYMi0HefFmo,5071
55
- voxagent-0.2.3.dist-info/METADATA,sha256=lxBJ9wIDJhCCLGb4P_ps9C0d5kfYVdTAHZNyDer9vYU,5685
56
- voxagent-0.2.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
57
- voxagent-0.2.3.dist-info/RECORD,,
56
+ voxagent-0.2.5.dist-info/METADATA,sha256=V29VGrxDI7moTtP7ps104Io9vF3jrZxcBn4xFJQly5c,5685
57
+ voxagent-0.2.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
58
+ voxagent-0.2.5.dist-info/RECORD,,