kiln_ai.sandbox.worker

Child-process entry point for code-tool execution.

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

The parent spawns this via multiprocessing (spawn context). Communication uses two queues (requests child->parent, responses parent->child).

  1"""Child-process entry point for code-tool execution.
  2
  3Stdlib only — no Pydantic / Kiln-model / DB / UI imports.
  4
  5The parent spawns this via ``multiprocessing`` (spawn context).
  6Communication uses two queues (requests child->parent, responses parent->child).
  7"""
  8
  9from __future__ import annotations
 10
 11import io
 12import json
 13import sys
 14import traceback
 15from multiprocessing import Queue
 16from typing import Any
 17
 18from kiln_ai.sandbox.entrypoint import call_entrypoint
 19from kiln_ai.sandbox.tools_api import install_tools_modules
 20
 21_TRUNCATION_LIMIT = 64 * 1024  # 64 KB
 22_TRUNCATION_MARKER = "\n...[truncated]"
 23
 24
 25def child_main(
 26    code: str,
 27    kwargs: dict[str, Any],
 28    requests: Queue,  # type: ignore[type-arg]
 29    responses: Queue,  # type: ignore[type-arg]
 30) -> None:
 31    """Entry point for the code-tool child process.
 32
 33    Puts exactly one ``result`` message on *requests* and then returns.
 34    """
 35    captured_stdout = io.StringIO()
 36    captured_stderr = io.StringIO()
 37    old_stdout = sys.stdout
 38    old_stderr = sys.stderr
 39    try:
 40        sys.stdout = captured_stdout  # type: ignore[assignment]
 41        sys.stderr = captured_stderr  # type: ignore[assignment]
 42
 43        install_tools_modules(requests, responses)
 44
 45        namespace: dict[str, Any] = {}
 46        exec(compile(code, "<code_tool>", "exec"), namespace)
 47
 48        run_fn = namespace.get("run")
 49        if run_fn is None:
 50            _put_error(
 51                requests,
 52                "Code does not define a 'run()' function",
 53                None,
 54                captured_stdout,
 55                captured_stderr,
 56            )
 57            return
 58        if not callable(run_fn):
 59            _put_error(
 60                requests,
 61                "'run' is defined but is not callable",
 62                None,
 63                captured_stdout,
 64                captured_stderr,
 65            )
 66            return
 67
 68        result = call_entrypoint(run_fn, kwargs)
 69        serialized = _serialize_result(result)
 70
 71        requests.put(
 72            {
 73                "type": "result",
 74                "ok": serialized,
 75                "stdout": _truncate(captured_stdout.getvalue()),
 76                "stderr": _truncate(captured_stderr.getvalue()),
 77            }
 78        )
 79    except Exception:
 80        tb = _trim_traceback()
 81        exc_info = sys.exc_info()
 82        _put_error(
 83            requests,
 84            str(exc_info[1]),
 85            tb,
 86            captured_stdout,
 87            captured_stderr,
 88        )
 89    finally:
 90        sys.stdout = old_stdout
 91        sys.stderr = old_stderr
 92
 93
 94# ---------------------------------------------------------------------------
 95# Helpers
 96# ---------------------------------------------------------------------------
 97
 98
 99def _serialize_result(value: Any) -> str:
100    """Serialize the return value of ``run()`` per the spec.
101
102    - ``str`` passes through as-is.
103    - ``dict/list/int/float/bool/None`` -> ``json.dumps``.
104    - Anything else -> raise with a clear message.
105    """
106    if isinstance(value, str):
107        return value
108    if isinstance(value, (dict, list, int, float, bool)) or value is None:
109        try:
110            return json.dumps(value, ensure_ascii=False)
111        except (TypeError, ValueError) as exc:
112            raise TypeError(
113                f"run() returned a value containing non-JSON-serializable data: {exc}"
114            ) from exc
115    raise TypeError(
116        f"run() must return str or JSON-serializable data, got {type(value).__name__}"
117    )
118
119
120def _trim_traceback() -> str:
121    """Format the current exception traceback, keeping only ``<code_tool>`` frames."""
122    _, exc_value, exc_tb = sys.exc_info()
123    if exc_value is None or exc_tb is None:
124        return ""
125
126    entries = traceback.extract_tb(exc_tb)
127    first_user_idx = None
128    for i, frame in enumerate(entries):
129        if frame.filename == "<code_tool>":
130            first_user_idx = i
131            break
132
133    if first_user_idx is not None:
134        entries = entries[first_user_idx:]
135
136    lines = ["Traceback (most recent call last):\n"]
137    lines.extend(traceback.format_list(entries))
138    lines.extend(traceback.format_exception_only(type(exc_value), exc_value))
139    return "".join(lines)
140
141
142def _truncate(text: str) -> str:
143    if len(text) <= _TRUNCATION_LIMIT:
144        return text
145    return text[:_TRUNCATION_LIMIT] + _TRUNCATION_MARKER
146
147
148def _put_error(
149    queue: Queue,  # type: ignore[type-arg]
150    error: str,
151    tb: str | None,
152    stdout: io.StringIO,
153    stderr: io.StringIO,
154) -> None:
155    queue.put(
156        {
157            "type": "result",
158            "error": error,
159            "traceback": tb,
160            "stdout": _truncate(stdout.getvalue()),
161            "stderr": _truncate(stderr.getvalue()),
162        }
163    )
def child_main( code: str, kwargs: dict[str, typing.Any], requests: <bound method BaseContext.Queue of <multiprocessing.context.DefaultContext object>>, responses: <bound method BaseContext.Queue of <multiprocessing.context.DefaultContext object>>) -> None:
26def child_main(
27    code: str,
28    kwargs: dict[str, Any],
29    requests: Queue,  # type: ignore[type-arg]
30    responses: Queue,  # type: ignore[type-arg]
31) -> None:
32    """Entry point for the code-tool child process.
33
34    Puts exactly one ``result`` message on *requests* and then returns.
35    """
36    captured_stdout = io.StringIO()
37    captured_stderr = io.StringIO()
38    old_stdout = sys.stdout
39    old_stderr = sys.stderr
40    try:
41        sys.stdout = captured_stdout  # type: ignore[assignment]
42        sys.stderr = captured_stderr  # type: ignore[assignment]
43
44        install_tools_modules(requests, responses)
45
46        namespace: dict[str, Any] = {}
47        exec(compile(code, "<code_tool>", "exec"), namespace)
48
49        run_fn = namespace.get("run")
50        if run_fn is None:
51            _put_error(
52                requests,
53                "Code does not define a 'run()' function",
54                None,
55                captured_stdout,
56                captured_stderr,
57            )
58            return
59        if not callable(run_fn):
60            _put_error(
61                requests,
62                "'run' is defined but is not callable",
63                None,
64                captured_stdout,
65                captured_stderr,
66            )
67            return
68
69        result = call_entrypoint(run_fn, kwargs)
70        serialized = _serialize_result(result)
71
72        requests.put(
73            {
74                "type": "result",
75                "ok": serialized,
76                "stdout": _truncate(captured_stdout.getvalue()),
77                "stderr": _truncate(captured_stderr.getvalue()),
78            }
79        )
80    except Exception:
81        tb = _trim_traceback()
82        exc_info = sys.exc_info()
83        _put_error(
84            requests,
85            str(exc_info[1]),
86            tb,
87            captured_stdout,
88            captured_stderr,
89        )
90    finally:
91        sys.stdout = old_stdout
92        sys.stderr = old_stderr

Entry point for the code-tool child process.

Puts exactly one result message on requests and then returns.