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(): injectskiln,kiln.tools,kiln.async_toolsintosys.modulesso user code canfrom kiln import toolsetc.
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
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).
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.
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.
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.
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.
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.
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.