kiln_ai.sandbox.tools_api

Sandbox-side kiln.tools bridge for code-tool children.

Stdlib only — no Pydantic / Kiln-model / DB / UI imports.

The synthetic-module surface (proxy behavior, list_tools wiring, and the typed exception classes ToolNotAllowed / ToolTimeout / ToolCallError) lives in kiln_ai.sandbox.tools_surface, shared with the test shim so runtime and tests present one definition of kiln.tools. This module provides the sandbox's real IPC bridge and wires it into that surface.

Provides:

  • ToolCallBridge: thread-safe IPC bridge (child -> parent tool calls)
  • install_tools_modules(): injects kiln, kiln.tools, kiln.async_tools into sys.modules so user code can from kiln import tools etc.

ToolNotAllowed / ToolTimeout / ToolCallError are re-exported here (they are defined in tools_surface) so existing importers keep working.

  1"""Sandbox-side ``kiln.tools`` bridge for code-tool children.
  2
  3Stdlib only — no Pydantic / Kiln-model / DB / UI imports.
  4
  5The synthetic-module *surface* (proxy behavior, ``list_tools`` wiring, and the
  6typed exception classes ``ToolNotAllowed`` / ``ToolTimeout`` / ``ToolCallError``)
  7lives in :mod:`kiln_ai.sandbox.tools_surface`, shared with the test shim so
  8runtime and tests present one definition of ``kiln.tools``. This module provides
  9the sandbox's real IPC bridge and wires it into that surface.
 10
 11Provides:
 12
 13* ``ToolCallBridge``: thread-safe IPC bridge (child -> parent tool calls)
 14* ``install_tools_modules()``: injects ``kiln``, ``kiln.tools``,
 15  ``kiln.async_tools`` into ``sys.modules`` so user code can
 16  ``from kiln import tools`` etc.
 17
 18``ToolNotAllowed`` / ``ToolTimeout`` / ``ToolCallError`` are re-exported here
 19(they are defined in ``tools_surface``) so existing importers keep working.
 20"""
 21
 22from __future__ import annotations
 23
 24import json
 25import threading
 26from multiprocessing import Queue
 27from typing import Any
 28
 29from kiln_ai.sandbox.tools_surface import (
 30    ToolCallError,
 31    ToolNotAllowed,
 32    ToolTimeout,
 33    install_tools_modules_for_bridge,
 34)
 35
 36__all__ = [
 37    "ToolCallBridge",
 38    "ToolCallError",
 39    "ToolNotAllowed",
 40    "ToolTimeout",
 41    "install_tools_modules",
 42]
 43
 44
 45# ---------------------------------------------------------------------------
 46# Child-side IPC bridge
 47# ---------------------------------------------------------------------------
 48
 49
 50class ToolCallBridge:
 51    """Thread-safe bridge between user code and the parent message pump.
 52
 53    One bridge per child process.  ``call()`` and ``list_tools()`` may be
 54    invoked from any user thread (or from ``asyncio.to_thread`` for the
 55    async mirror).
 56    """
 57
 58    def __init__(self, requests: Queue, responses: Queue) -> None:  # type: ignore[type-arg]
 59        self._requests = requests
 60        self._responses = responses
 61        self._lock = threading.Lock()
 62        self._next_id = 0
 63        self._pending: dict[int, _PendingCall] = {}
 64
 65    # -- public API --
 66
 67    def call(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
 68        """Issue a ``tool_call`` to the parent; block until reply.
 69
 70        Returns the raw output string on success.  Raises one of the
 71        typed exceptions on failure.
 72        """
 73        try:
 74            safe_kwargs = json.loads(json.dumps(kwargs, ensure_ascii=False))
 75        except (TypeError, ValueError) as exc:
 76            raise ToolCallError(
 77                tool=name,
 78                message=f"tool arguments must be JSON-serializable: {exc}",
 79            ) from exc
 80
 81        # Serialize positional args so the parent can produce a helpful error
 82        try:
 83            safe_args = json.loads(json.dumps(list(args), ensure_ascii=False))
 84        except (TypeError, ValueError):
 85            safe_args = [repr(a) for a in args]
 86
 87        call_id, pending = self._allocate()
 88        msg: dict[str, Any] = {
 89            "type": "tool_call",
 90            "call_id": call_id,
 91            "tool_name": name,
 92            "arguments": safe_kwargs,
 93        }
 94        if safe_args:
 95            msg["positional_args"] = safe_args
 96        self._requests.put(msg)
 97        pending.event.wait()
 98        return self._resolve(call_id, name, pending)
 99
100    def list_tools(self) -> list[dict[str, Any]]:
101        """Ask the parent for the allowlisted tool definitions."""
102        call_id, pending = self._allocate()
103        self._requests.put({"type": "list_tools", "call_id": call_id})
104        pending.event.wait()
105        msg = pending.result
106        assert msg is not None
107        if "ok_list" in msg:
108            return msg["ok_list"]  # type: ignore[return-value]
109        err = msg.get("error", {})
110        raise ToolCallError(
111            tool="list_tools",
112            message=err.get("message", "unknown error"),
113        )
114
115    def start_dispatcher(self) -> None:
116        """Start the daemon thread that reads parent replies."""
117        t = threading.Thread(target=self._dispatch_loop, daemon=True)
118        t.start()
119
120    # -- internals --
121
122    def _allocate(self) -> tuple[int, "_PendingCall"]:
123        with self._lock:
124            cid = self._next_id
125            self._next_id += 1
126            p = _PendingCall()
127            self._pending[cid] = p
128            return cid, p
129
130    def _dispatch_loop(self) -> None:
131        try:
132            while True:
133                try:
134                    msg = self._responses.get()
135                except (EOFError, OSError, ValueError):
136                    break
137                cid = msg.get("call_id")
138                with self._lock:
139                    pending = self._pending.pop(cid, None)
140                if pending is not None:
141                    pending.result = msg
142                    pending.event.set()
143        finally:
144            with self._lock:
145                orphans = list(self._pending.values())
146                self._pending.clear()
147            for p in orphans:
148                p.result = {
149                    "error": {
150                        "kind": "call_error",
151                        "message": "Parent process disconnected or queue closed.",
152                    }
153                }
154                p.event.set()
155
156    def _resolve(self, call_id: int, name: str, pending: "_PendingCall") -> str:
157        msg = pending.result
158        assert msg is not None
159        if "ok" in msg:
160            return msg["ok"]
161        err = msg.get("error", {})
162        kind = err.get("kind", "call_error")
163        message = err.get("message", "unknown error")
164        raw = err.get("raw")
165        if kind == "not_allowed":
166            raise ToolNotAllowed(tool=name, message=message)
167        if kind == "timeout":
168            raise ToolTimeout(tool=name, message=message)
169        raise ToolCallError(tool=name, message=message, raw=raw)
170
171
172class _PendingCall:
173    __slots__ = ("event", "result")
174
175    def __init__(self) -> None:
176        self.event = threading.Event()
177        self.result: dict[str, Any] | None = None
178
179
180# ---------------------------------------------------------------------------
181# Module installation
182# ---------------------------------------------------------------------------
183
184
185def install_tools_modules(
186    requests: Queue,
187    responses: Queue,  # type: ignore[type-arg]
188) -> ToolCallBridge:
189    """Create and install the synthetic ``kiln`` package into ``sys.modules``.
190
191    Returns the bridge so the caller can inspect it if needed.
192    """
193    bridge = ToolCallBridge(requests, responses)
194    bridge.start_dispatcher()
195    install_tools_modules_for_bridge(bridge)
196    return bridge
class ToolCallBridge:
 51class ToolCallBridge:
 52    """Thread-safe bridge between user code and the parent message pump.
 53
 54    One bridge per child process.  ``call()`` and ``list_tools()`` may be
 55    invoked from any user thread (or from ``asyncio.to_thread`` for the
 56    async mirror).
 57    """
 58
 59    def __init__(self, requests: Queue, responses: Queue) -> None:  # type: ignore[type-arg]
 60        self._requests = requests
 61        self._responses = responses
 62        self._lock = threading.Lock()
 63        self._next_id = 0
 64        self._pending: dict[int, _PendingCall] = {}
 65
 66    # -- public API --
 67
 68    def call(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
 69        """Issue a ``tool_call`` to the parent; block until reply.
 70
 71        Returns the raw output string on success.  Raises one of the
 72        typed exceptions on failure.
 73        """
 74        try:
 75            safe_kwargs = json.loads(json.dumps(kwargs, ensure_ascii=False))
 76        except (TypeError, ValueError) as exc:
 77            raise ToolCallError(
 78                tool=name,
 79                message=f"tool arguments must be JSON-serializable: {exc}",
 80            ) from exc
 81
 82        # Serialize positional args so the parent can produce a helpful error
 83        try:
 84            safe_args = json.loads(json.dumps(list(args), ensure_ascii=False))
 85        except (TypeError, ValueError):
 86            safe_args = [repr(a) for a in args]
 87
 88        call_id, pending = self._allocate()
 89        msg: dict[str, Any] = {
 90            "type": "tool_call",
 91            "call_id": call_id,
 92            "tool_name": name,
 93            "arguments": safe_kwargs,
 94        }
 95        if safe_args:
 96            msg["positional_args"] = safe_args
 97        self._requests.put(msg)
 98        pending.event.wait()
 99        return self._resolve(call_id, name, pending)
100
101    def list_tools(self) -> list[dict[str, Any]]:
102        """Ask the parent for the allowlisted tool definitions."""
103        call_id, pending = self._allocate()
104        self._requests.put({"type": "list_tools", "call_id": call_id})
105        pending.event.wait()
106        msg = pending.result
107        assert msg is not None
108        if "ok_list" in msg:
109            return msg["ok_list"]  # type: ignore[return-value]
110        err = msg.get("error", {})
111        raise ToolCallError(
112            tool="list_tools",
113            message=err.get("message", "unknown error"),
114        )
115
116    def start_dispatcher(self) -> None:
117        """Start the daemon thread that reads parent replies."""
118        t = threading.Thread(target=self._dispatch_loop, daemon=True)
119        t.start()
120
121    # -- internals --
122
123    def _allocate(self) -> tuple[int, "_PendingCall"]:
124        with self._lock:
125            cid = self._next_id
126            self._next_id += 1
127            p = _PendingCall()
128            self._pending[cid] = p
129            return cid, p
130
131    def _dispatch_loop(self) -> None:
132        try:
133            while True:
134                try:
135                    msg = self._responses.get()
136                except (EOFError, OSError, ValueError):
137                    break
138                cid = msg.get("call_id")
139                with self._lock:
140                    pending = self._pending.pop(cid, None)
141                if pending is not None:
142                    pending.result = msg
143                    pending.event.set()
144        finally:
145            with self._lock:
146                orphans = list(self._pending.values())
147                self._pending.clear()
148            for p in orphans:
149                p.result = {
150                    "error": {
151                        "kind": "call_error",
152                        "message": "Parent process disconnected or queue closed.",
153                    }
154                }
155                p.event.set()
156
157    def _resolve(self, call_id: int, name: str, pending: "_PendingCall") -> str:
158        msg = pending.result
159        assert msg is not None
160        if "ok" in msg:
161            return msg["ok"]
162        err = msg.get("error", {})
163        kind = err.get("kind", "call_error")
164        message = err.get("message", "unknown error")
165        raw = err.get("raw")
166        if kind == "not_allowed":
167            raise ToolNotAllowed(tool=name, message=message)
168        if kind == "timeout":
169            raise ToolTimeout(tool=name, message=message)
170        raise ToolCallError(tool=name, message=message, raw=raw)

Thread-safe bridge between user code and the parent message pump.

One bridge per child process. call() and list_tools() may be invoked from any user thread (or from asyncio.to_thread for the async mirror).

ToolCallBridge( requests: <bound method BaseContext.Queue of <multiprocessing.context.DefaultContext object>>, responses: <bound method BaseContext.Queue of <multiprocessing.context.DefaultContext object>>)
59    def __init__(self, requests: Queue, responses: Queue) -> None:  # type: ignore[type-arg]
60        self._requests = requests
61        self._responses = responses
62        self._lock = threading.Lock()
63        self._next_id = 0
64        self._pending: dict[int, _PendingCall] = {}
def call( self, name: str, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> str:
68    def call(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
69        """Issue a ``tool_call`` to the parent; block until reply.
70
71        Returns the raw output string on success.  Raises one of the
72        typed exceptions on failure.
73        """
74        try:
75            safe_kwargs = json.loads(json.dumps(kwargs, ensure_ascii=False))
76        except (TypeError, ValueError) as exc:
77            raise ToolCallError(
78                tool=name,
79                message=f"tool arguments must be JSON-serializable: {exc}",
80            ) from exc
81
82        # Serialize positional args so the parent can produce a helpful error
83        try:
84            safe_args = json.loads(json.dumps(list(args), ensure_ascii=False))
85        except (TypeError, ValueError):
86            safe_args = [repr(a) for a in args]
87
88        call_id, pending = self._allocate()
89        msg: dict[str, Any] = {
90            "type": "tool_call",
91            "call_id": call_id,
92            "tool_name": name,
93            "arguments": safe_kwargs,
94        }
95        if safe_args:
96            msg["positional_args"] = safe_args
97        self._requests.put(msg)
98        pending.event.wait()
99        return self._resolve(call_id, name, pending)

Issue a tool_call to the parent; block until reply.

Returns the raw output string on success. Raises one of the typed exceptions on failure.

def list_tools(self) -> list[dict[str, typing.Any]]:
101    def list_tools(self) -> list[dict[str, Any]]:
102        """Ask the parent for the allowlisted tool definitions."""
103        call_id, pending = self._allocate()
104        self._requests.put({"type": "list_tools", "call_id": call_id})
105        pending.event.wait()
106        msg = pending.result
107        assert msg is not None
108        if "ok_list" in msg:
109            return msg["ok_list"]  # type: ignore[return-value]
110        err = msg.get("error", {})
111        raise ToolCallError(
112            tool="list_tools",
113            message=err.get("message", "unknown error"),
114        )

Ask the parent for the allowlisted tool definitions.

def start_dispatcher(self) -> None:
116    def start_dispatcher(self) -> None:
117        """Start the daemon thread that reads parent replies."""
118        t = threading.Thread(target=self._dispatch_loop, daemon=True)
119        t.start()

Start the daemon thread that reads parent replies.

class ToolCallError(builtins.Exception):
50class ToolCallError(Exception):
51    """Catch-all for nested tool-call failures."""
52
53    def __init__(self, tool: str, message: str, raw: str | None = None) -> None:
54        self.tool = tool
55        self.message = message
56        self.raw = raw
57        super().__init__(message)

Catch-all for nested tool-call failures.

ToolCallError(tool: str, message: str, raw: str | None = None)
53    def __init__(self, tool: str, message: str, raw: str | None = None) -> None:
54        self.tool = tool
55        self.message = message
56        self.raw = raw
57        super().__init__(message)
tool
message
raw
class ToolNotAllowed(builtins.Exception):
30class ToolNotAllowed(Exception):
31    """The requested tool is not in this code tool's allowlist."""
32
33    def __init__(self, tool: str, message: str) -> None:
34        self.tool = tool
35        self.message = message
36        self.raw: str | None = None
37        super().__init__(message)

The requested tool is not in this code tool's allowlist.

ToolNotAllowed(tool: str, message: str)
33    def __init__(self, tool: str, message: str) -> None:
34        self.tool = tool
35        self.message = message
36        self.raw: str | None = None
37        super().__init__(message)
tool
message
raw: str | None
class ToolTimeout(builtins.Exception):
40class ToolTimeout(Exception):
41    """A nested tool call timed out."""
42
43    def __init__(self, tool: str, message: str) -> None:
44        self.tool = tool
45        self.message = message
46        self.raw: str | None = None
47        super().__init__(message)

A nested tool call timed out.

ToolTimeout(tool: str, message: str)
43    def __init__(self, tool: str, message: str) -> None:
44        self.tool = tool
45        self.message = message
46        self.raw: str | None = None
47        super().__init__(message)
tool
message
raw: str | None
def install_tools_modules( requests: <bound method BaseContext.Queue of <multiprocessing.context.DefaultContext object>>, responses: <bound method BaseContext.Queue of <multiprocessing.context.DefaultContext object>>) -> ToolCallBridge:
186def install_tools_modules(
187    requests: Queue,
188    responses: Queue,  # type: ignore[type-arg]
189) -> ToolCallBridge:
190    """Create and install the synthetic ``kiln`` package into ``sys.modules``.
191
192    Returns the bridge so the caller can inspect it if needed.
193    """
194    bridge = ToolCallBridge(requests, responses)
195    bridge.start_dispatcher()
196    install_tools_modules_for_bridge(bridge)
197    return bridge

Create and install the synthetic kiln package into sys.modules.

Returns the bridge so the caller can inspect it if needed.