Repository navigation
feat(opencode-agents): sandbox loopback Python tool callables #232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| """Process-isolated execution for AI Config tool callables. | ||
|
|
||
| The OpenCode handler serves plain Python tools through a loopback MCP bridge. | ||
| Those callables run in a spawned child process so a tool cannot read the | ||
| handler's in-process state (module globals, open connections, and similar). | ||
|
|
||
| ``NativeTool`` entries never pass through this module; OpenCode runs them | ||
| inside ``opencode serve``. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import inspect | ||
| import multiprocessing | ||
| import pickle | ||
| from collections.abc import Awaitable, Callable | ||
| from concurrent.futures import ProcessPoolExecutor | ||
| from typing import Any | ||
|
|
||
| ToolCallable = Callable[[Any], Awaitable[Any] | Any] | ||
|
|
||
| _DEFAULT_TIMEOUT_SECONDS = 600.0 | ||
|
|
||
|
|
||
| class SandboxError(RuntimeError): | ||
| """The sandbox child failed or could not start.""" | ||
|
|
||
|
|
||
| class Sandbox: | ||
| """Runs *fn* in a spawned process when invoked. | ||
|
|
||
| Authors may wrap a tool explicitly. The OpenCode loopback bridge also | ||
| wraps bridged callables before registration so every served Python tool | ||
| is isolated even when the author passes a plain function. | ||
| """ | ||
|
|
||
| __slots__ = ("_fn",) | ||
|
|
||
| def __init__(self, fn: ToolCallable) -> None: | ||
| self._fn = fn | ||
|
|
||
| @property | ||
| def wrapped(self) -> ToolCallable: | ||
| """The inner callable (before tracking wrappers).""" | ||
| return self._fn | ||
|
|
||
| def __call__(self, arguments: Any) -> Awaitable[Any] | Any: | ||
| return invoke_tool_callable(self._fn, arguments) | ||
|
|
||
| def __repr__(self) -> str: | ||
| return f"Sandbox({self._fn!r})" | ||
|
|
||
|
|
||
| def _execute_pickled(payload: bytes) -> Any: | ||
| fn, arguments = pickle.loads(payload) | ||
| result = fn(arguments) | ||
| if inspect.iscoroutine(result): | ||
| return asyncio.run(result) | ||
| return result | ||
|
|
||
|
|
||
| def _spawn_sync(fn: ToolCallable, arguments: Any, *, timeout: float) -> Any: | ||
| try: | ||
| payload = pickle.dumps((fn, arguments)) | ||
| except (pickle.PicklingError, AttributeError) as exc: | ||
| raise SandboxError( | ||
| "Tool callable is not picklable; define it at module scope " | ||
| "instead of as a closure or lambda." | ||
| ) from exc | ||
| ctx = multiprocessing.get_context("spawn") | ||
| with ProcessPoolExecutor(max_workers=1, mp_context=ctx) as pool: | ||
| future = pool.submit(_execute_pickled, payload) | ||
| try: | ||
| return future.result(timeout=timeout) | ||
| except TimeoutError as exc: | ||
| raise SandboxError("Tool callable timed out in the sandbox") from exc | ||
| except Exception as exc: | ||
| if isinstance(exc, SandboxError): | ||
| raise | ||
| raise SandboxError(str(exc)) from exc | ||
|
|
||
|
|
||
| async def invoke_tool_callable( | ||
| fn: ToolCallable, | ||
| arguments: Any, | ||
| *, | ||
| timeout: float = _DEFAULT_TIMEOUT_SECONDS, | ||
| ) -> Any: | ||
| """Run *fn* with *arguments* in a spawned child process.""" | ||
| loop = asyncio.get_running_loop() | ||
| return await asyncio.wait_for( | ||
| loop.run_in_executor(None, lambda: _spawn_sync(fn, arguments, timeout=timeout)), | ||
| timeout=timeout + 1.0, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| """Process sandbox for AI Config tool callables.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
| from launchdarkly_ai_server import Sandbox, SandboxError, invoke_tool_callable | ||
|
|
||
| _RUNTIME_SECRET: str | None = None | ||
|
|
||
|
|
||
| def _runtime_secret_probe(_args: dict[str, object]) -> str: | ||
| return _RUNTIME_SECRET or "missing" | ||
|
|
||
|
|
||
| async def test_invoke_tool_callable_does_not_see_parent_globals() -> None: | ||
| global _RUNTIME_SECRET | ||
| _RUNTIME_SECRET = "parent-only" | ||
| result = await invoke_tool_callable(_runtime_secret_probe, {}) | ||
| assert result == "missing" | ||
|
|
||
|
|
||
| def _sync_echo(args: dict[str, object]) -> str: | ||
| return str(args.get("city")) | ||
|
|
||
|
|
||
| async def _async_echo(args: dict[str, object]) -> dict[str, object]: | ||
| return args | ||
|
|
||
|
|
||
| async def test_invoke_tool_callable_runs_sync_callables() -> None: | ||
| result = await invoke_tool_callable(_sync_echo, {"city": "Oslo"}) | ||
| assert result == "Oslo" | ||
|
|
||
|
|
||
| async def test_sandbox_delegates_to_inner() -> None: | ||
| boxed = Sandbox(_async_echo) | ||
| assert await boxed({"x": 1}) == {"x": 1} | ||
|
|
||
|
|
||
| async def test_non_picklable_callable_raises_sandbox_error() -> None: | ||
| with pytest.raises(SandboxError, match="picklable"): | ||
| await invoke_tool_callable(lambda _args: "nope", {}) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -322,13 +322,27 @@ def _call_tool( | |
| } | ||
|
|
||
|
|
||
| def _sandbox_target(fn: ToolCallable) -> ToolCallable: | ||
| from launchdarkly_ai_server.tool_sandbox import Sandbox | ||
|
|
||
| if isinstance(fn, Sandbox): | ||
| return fn.wrapped | ||
| return fn | ||
|
|
||
|
|
||
| async def _invoke(fn: ToolCallable, arguments: Any) -> tuple[str, bool]: | ||
| from launchdarkly_ai_server import tool_result_string | ||
| from launchdarkly_ai_server.tool_sandbox import invoke_tool_callable | ||
|
|
||
| try: | ||
| result = fn(arguments) | ||
| if asyncio.iscoroutine(result): | ||
| result = await result | ||
| target = getattr(fn, "_ld_tool_target", None) | ||
| if target is not None: | ||
| track = getattr(fn, "_ld_tool_track", None) | ||
| if track is not None: | ||
| track() | ||
| result = await invoke_tool_callable(_sandbox_target(target), arguments) | ||
| else: | ||
| result = await invoke_tool_callable(fn, arguments) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sandbox pickles trajectory wrapper closuresHigh Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit b77e721. Configure here. |
||
| return tool_result_string(result), False | ||
| except Exception as exc: | ||
| return str(exc), True | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sandbox timeout leaves child running
Medium Severity
After
future.resulttimes out, theProcessPoolExecutorcontext still shuts down withwait=True, so a hung child is never killed and the worker thread blocks. The outerasyncio.wait_forthen raisesTimeoutErrorwhile that process keeps running and occupies the default executor.Reviewed by Cursor Bugbot for commit b77e721. Configure here.