kiln_ai.sandbox.test_code_tool_execution

Tests for the code-tool execution engine.

Child/protocol tests spawn real child processes. Parent-side tests use mock tool doubles. Shaped after the existing test_sandbox_worker.py suite.

   1"""Tests for the code-tool execution engine.
   2
   3Child/protocol tests spawn real child processes. Parent-side tests use
   4mock tool doubles. Shaped after the existing ``test_sandbox_worker.py`` suite.
   5"""
   6
   7import asyncio
   8import json
   9import textwrap
  10from unittest.mock import patch
  11
  12import pytest
  13
  14from kiln_ai.datamodel.code_tool import CodeTool
  15from kiln_ai.datamodel.project import Project
  16from kiln_ai.sandbox.spawn import _spawn_lock
  17from kiln_ai.tools.base_tool import (
  18    KilnToolInterface,
  19    ToolCallDefinition,
  20    ToolCallResult,
  21)
  22from kiln_ai.tools.code_tool import (
  23    PythonCodeTool,
  24    ToolCallLogEntry,
  25)
  26from kiln_ai.tools.sandbox_bridge import (
  27    CODE_SANDBOX_MAX_CONCURRENCY,
  28    NestedToolServer,
  29    _depth,
  30)
  31
  32# ---------------------------------------------------------------------------
  33# Helpers
  34# ---------------------------------------------------------------------------
  35
  36VALID_SCHEMA = {
  37    "type": "object",
  38    "properties": {"x": {"type": "string"}},
  39}
  40EMPTY_SCHEMA = {"type": "object", "properties": {}}
  41
  42
  43def _make_code_tool(code: str, **overrides) -> CodeTool:
  44    defaults = {
  45        "name": "Test Tool",
  46        "tool_function_name": "test_tool",
  47        "tool_description": "A test tool",
  48        "parameters_schema": VALID_SCHEMA,
  49        "code": code,
  50        "timeout_seconds": 10,
  51    }
  52    defaults.update(overrides)
  53    return CodeTool(**defaults)
  54
  55
  56def _make_project(tmp_path) -> Project:
  57    p = Project(name="test_project", path=tmp_path / "project")
  58    p.save_to_file()
  59    return p
  60
  61
  62def _make_python_code_tool(
  63    tmp_path,
  64    code: str,
  65    tool_allowlist=None,
  66    tool_call_recorder=None,
  67    **overrides,
  68) -> PythonCodeTool:
  69    project = _make_project(tmp_path)
  70    ct = _make_code_tool(
  71        code,
  72        tool_allowlist=tool_allowlist or [],
  73        **overrides,
  74    )
  75    ct.parent = project
  76    return PythonCodeTool(
  77        ct,
  78        project,
  79        tool_call_recorder=tool_call_recorder,
  80    )
  81
  82
  83class FakeTool(KilnToolInterface):
  84    """Minimal tool double for testing nested calls.
  85
  86    IMPORTANT: ``fn_name`` intentionally DIFFERS from the tool_id slug
  87    (e.g. ``fn_name="fake_add"`` for ``tool_id="kiln_tool::add_numbers"``).
  88    This ensures tests catch name-derivation bugs where the dispatch map
  89    would use the slug instead of ``tool.name()``.
  90    """
  91
  92    def __init__(
  93        self,
  94        tool_id: str,
  95        fn_name: str,
  96        fn_desc: str = "fake",
  97        params: dict | None = None,
  98        result: ToolCallResult | None = None,
  99        delay: float = 0,
 100    ):
 101        self._id = tool_id
 102        self._name = fn_name
 103        self._desc = fn_desc
 104        self._params = params or EMPTY_SCHEMA
 105        self._result = result or ToolCallResult(output="ok")
 106        self._delay = delay
 107
 108    async def id(self):
 109        return self._id
 110
 111    async def name(self):
 112        return self._name
 113
 114    async def description(self):
 115        return self._desc
 116
 117    async def toolcall_definition(self) -> ToolCallDefinition:
 118        return {
 119            "type": "function",
 120            "function": {
 121                "name": self._name,
 122                "description": self._desc,
 123                "parameters": self._params,
 124            },
 125        }
 126
 127    async def run(self, context=None, **kwargs) -> ToolCallResult:
 128        if self._delay > 0:
 129            await asyncio.sleep(self._delay)
 130        return self._result
 131
 132
 133# ---------------------------------------------------------------------------
 134# Child / protocol tests (real spawns)
 135# ---------------------------------------------------------------------------
 136
 137
 138class TestChildSyncRun:
 139    @pytest.mark.asyncio
 140    async def test_sync_run_returns_string(self, tmp_path):
 141        tool = _make_python_code_tool(
 142            tmp_path,
 143            'def run(x):\n    return "hello " + x\n',
 144        )
 145        result = await tool.run(None, x="world")
 146        assert not result.is_error
 147        assert result.output == "hello world"
 148
 149    @pytest.mark.asyncio
 150    async def test_sync_run_returns_dict(self, tmp_path):
 151        tool = _make_python_code_tool(
 152            tmp_path,
 153            'def run(x):\n    return {"value": x}\n',
 154        )
 155        result = await tool.run(None, x="test")
 156        assert not result.is_error
 157        assert json.loads(result.output) == {"value": "test"}
 158
 159    @pytest.mark.asyncio
 160    async def test_sync_run_returns_none(self, tmp_path):
 161        tool = _make_python_code_tool(
 162            tmp_path,
 163            "def run(x):\n    pass\n",
 164        )
 165        result = await tool.run(None, x="test")
 166        assert not result.is_error
 167        assert result.output == "null"
 168
 169
 170class TestChildAsyncRun:
 171    @pytest.mark.asyncio
 172    async def test_async_run_returns_string(self, tmp_path):
 173        tool = _make_python_code_tool(
 174            tmp_path,
 175            textwrap.dedent("""\
 176                import asyncio
 177                async def run(x):
 178                    async def greet(name):
 179                        return "hi " + name
 180                    results = await asyncio.gather(greet(x), greet(x + "!"))
 181                    return " ".join(results)
 182            """),
 183        )
 184        result = await tool.run(None, x="a")
 185        assert not result.is_error
 186        assert result.output == "hi a hi a!"
 187
 188    @pytest.mark.asyncio
 189    async def test_asyncio_run_inside_async_errors(self, tmp_path):
 190        tool = _make_python_code_tool(
 191            tmp_path,
 192            textwrap.dedent("""\
 193                import asyncio
 194                async def helper():
 195                    return 1
 196                async def run(x):
 197                    return asyncio.run(helper())
 198            """),
 199        )
 200        result = await tool.run(None, x="test")
 201        assert result.is_error
 202        assert (
 203            "cannot be called from a running event loop" in result.output.lower()
 204            or "cannot" in result.output.lower()
 205        )
 206
 207
 208class TestReturnSerialization:
 209    @pytest.mark.asyncio
 210    @pytest.mark.parametrize(
 211        "code,expected",
 212        [
 213            ('def run(x):\n    return "raw"\n', "raw"),
 214            ("def run(x):\n    return 42\n", "42"),
 215            ("def run(x):\n    return 3.14\n", "3.14"),
 216            ("def run(x):\n    return True\n", "true"),
 217            ("def run(x):\n    return False\n", "false"),
 218            ("def run(x):\n    return None\n", "null"),
 219            ("def run(x):\n    return [1, 2]\n", "[1, 2]"),
 220            ('def run(x):\n    return {"k": "v"}\n', '{"k": "v"}'),
 221        ],
 222        ids=[
 223            "str",
 224            "int",
 225            "float",
 226            "bool_true",
 227            "bool_false",
 228            "none",
 229            "list",
 230            "dict",
 231        ],
 232    )
 233    async def test_serialization(self, tmp_path, code, expected):
 234        tool = _make_python_code_tool(tmp_path, code)
 235        result = await tool.run(None, x="test")
 236        assert not result.is_error
 237        assert result.output == expected
 238
 239    @pytest.mark.asyncio
 240    async def test_non_serializable_type_errors(self, tmp_path):
 241        tool = _make_python_code_tool(
 242            tmp_path,
 243            "def run(x):\n    return object()\n",
 244        )
 245        result = await tool.run(None, x="test")
 246        assert result.is_error
 247        assert "must return str or JSON-serializable" in result.output
 248
 249    @pytest.mark.asyncio
 250    async def test_non_serializable_nested_value_errors(self, tmp_path):
 251        tool = _make_python_code_tool(
 252            tmp_path,
 253            "def run(x):\n    return {'fn': lambda: None}\n",
 254        )
 255        result = await tool.run(None, x="test")
 256        assert result.is_error
 257        assert "non-JSON-serializable" in result.output
 258
 259    @pytest.mark.asyncio
 260    async def test_string_passthrough_no_parsing(self, tmp_path):
 261        """JSON-shaped string returned by run() comes back as-is, not parsed."""
 262        tool = _make_python_code_tool(
 263            tmp_path,
 264            'def run(x):\n    return \'{"key": "value"}\'\n',
 265        )
 266        result = await tool.run(None, x="test")
 267        assert not result.is_error
 268        assert result.output == '{"key": "value"}'
 269
 270
 271class TestStdoutStderr:
 272    @pytest.mark.asyncio
 273    async def test_stdout_captured(self, tmp_path):
 274        project = _make_project(tmp_path)
 275        ct = _make_code_tool(
 276            'import sys\ndef run(x):\n    sys.stdout.write("debug")\n    return "ok"\n',
 277        )
 278        ct.parent = project
 279        pct = PythonCodeTool(ct, project)
 280        outcome = await pct._invoke(None, {"x": "test"})
 281        assert outcome.ok == "ok"
 282        assert "debug" in outcome.stdout
 283
 284    @pytest.mark.asyncio
 285    async def test_stdout_truncation(self, tmp_path):
 286        project = _make_project(tmp_path)
 287        ct = _make_code_tool(
 288            'import sys\ndef run(x):\n    sys.stdout.write("A" * 100000)\n    return "ok"\n',
 289        )
 290        ct.parent = project
 291        pct = PythonCodeTool(ct, project)
 292        outcome = await pct._invoke(None, {"x": "test"})
 293        assert outcome.ok == "ok"
 294        assert len(outcome.stdout) <= 64 * 1024 + 50
 295        assert "truncated" in outcome.stdout
 296
 297
 298class TestTraceback:
 299    @pytest.mark.asyncio
 300    async def test_traceback_shows_code_tool_lines(self, tmp_path):
 301        tool = _make_python_code_tool(
 302            tmp_path,
 303            textwrap.dedent("""\
 304                def helper():
 305                    raise ValueError("kaboom")
 306                def run(x):
 307                    helper()
 308            """),
 309        )
 310        result = await tool.run(None, x="test")
 311        assert result.is_error
 312        assert "kaboom" in result.output
 313        assert "<code_tool>" in result.output
 314        assert "worker.py" not in result.output
 315
 316
 317class TestMissingRun:
 318    @pytest.mark.asyncio
 319    async def test_missing_run_defense(self, tmp_path):
 320        """Even if save-time validation is bypassed, child handles missing run()."""
 321        project = _make_project(tmp_path)
 322        ct = CodeTool.__new__(CodeTool)
 323        object.__setattr__(
 324            ct,
 325            "__dict__",
 326            {
 327                "name": "bad",
 328                "tool_function_name": "bad",
 329                "tool_description": "bad",
 330                "parameters_schema": EMPTY_SCHEMA,
 331                "code": "x = 1\n",
 332                "timeout_seconds": 10,
 333                "tool_allowlist": [],
 334                "description": None,
 335                "is_archived": False,
 336                "id": "test123",
 337                "v": 1,
 338                "created_at": None,
 339                "created_by": None,
 340                "path": None,
 341            },
 342        )
 343        object.__setattr__(ct, "__pydantic_fields_set__", set())
 344        pct = PythonCodeTool(ct, project)
 345        result = await pct.run(None)
 346        assert result.is_error
 347        assert "run" in result.output.lower()
 348
 349
 350class TestImportForms:
 351    @pytest.mark.asyncio
 352    async def test_from_kiln_import_tools(self, tmp_path):
 353        tool = _make_python_code_tool(
 354            tmp_path,
 355            textwrap.dedent("""\
 356                from kiln import tools
 357                def run(x):
 358                    return type(tools).__name__
 359            """),
 360        )
 361        result = await tool.run(None, x="test")
 362        assert not result.is_error
 363
 364    @pytest.mark.asyncio
 365    async def test_import_kiln_tools(self, tmp_path):
 366        tool = _make_python_code_tool(
 367            tmp_path,
 368            textwrap.dedent("""\
 369                import kiln.tools
 370                def run(x):
 371                    return type(kiln.tools).__name__
 372            """),
 373        )
 374        result = await tool.run(None, x="test")
 375        assert not result.is_error
 376
 377    @pytest.mark.asyncio
 378    async def test_from_kiln_tools_import_exception(self, tmp_path):
 379        tool = _make_python_code_tool(
 380            tmp_path,
 381            textwrap.dedent("""\
 382                from kiln.tools import ToolCallError
 383                def run(x):
 384                    return ToolCallError.__name__
 385            """),
 386        )
 387        result = await tool.run(None, x="test")
 388        assert not result.is_error
 389        assert result.output == "ToolCallError"
 390
 391    @pytest.mark.asyncio
 392    async def test_from_kiln_import_async_tools(self, tmp_path):
 393        tool = _make_python_code_tool(
 394            tmp_path,
 395            textwrap.dedent("""\
 396                from kiln import async_tools
 397                def run(x):
 398                    return type(async_tools).__name__
 399            """),
 400        )
 401        result = await tool.run(None, x="test")
 402        assert not result.is_error
 403
 404    @pytest.mark.asyncio
 405    async def test_exception_classes_identical_across_modules(self, tmp_path):
 406        tool = _make_python_code_tool(
 407            tmp_path,
 408            textwrap.dedent("""\
 409                from kiln import tools, async_tools
 410                def run(x):
 411                    same_not_allowed = tools.ToolNotAllowed is async_tools.ToolNotAllowed
 412                    same_timeout = tools.ToolTimeout is async_tools.ToolTimeout
 413                    same_call_error = tools.ToolCallError is async_tools.ToolCallError
 414                    return str(same_not_allowed and same_timeout and same_call_error)
 415            """),
 416        )
 417        result = await tool.run(None, x="test")
 418        assert not result.is_error
 419        assert result.output == "True"
 420
 421
 422class TestJsonUnsafeKwargs:
 423    @pytest.mark.asyncio
 424    async def test_json_unsafe_kwargs_raise_in_frame(self, tmp_path):
 425        """Non-JSON-serializable tool kwargs raise ToolCallError inside child."""
 426        code = textwrap.dedent("""\
 427            from kiln.tools import ToolCallError
 428            from kiln import tools
 429            def run(x):
 430                try:
 431                    tools.some_tool(bad=object())
 432                except ToolCallError as e:
 433                    return f"caught: {e.tool}"
 434                return "no error"
 435        """)
 436        tool = _make_python_code_tool(tmp_path, code)
 437        result = await tool.run(None, x="test")
 438        assert not result.is_error
 439        assert "caught: some_tool" in result.output
 440
 441
 442# ---------------------------------------------------------------------------
 443# Parent-side tests (mock tools)
 444# ---------------------------------------------------------------------------
 445
 446
 447class TestHappyPath:
 448    @pytest.mark.asyncio
 449    async def test_simple_run(self, tmp_path):
 450        tool = _make_python_code_tool(
 451            tmp_path,
 452            'def run(x):\n    return "result_" + x\n',
 453        )
 454        result = await tool.run(None, x="abc")
 455        assert not result.is_error
 456        assert result.output == "result_abc"
 457
 458
 459class TestNestedToolCalls:
 460    @pytest.mark.asyncio
 461    async def test_nested_tool_success(self, tmp_path):
 462        fake = FakeTool(
 463            "kiln_tool::add_numbers",
 464            "fake_add",
 465            params=EMPTY_SCHEMA,
 466            result=ToolCallResult(output="42"),
 467        )
 468        code = textwrap.dedent("""\
 469            from kiln import tools
 470            def run(x):
 471                result = tools.fake_add()
 472                return "got: " + result
 473        """)
 474        tool = _make_python_code_tool(
 475            tmp_path,
 476            code,
 477            tool_allowlist=["kiln_tool::add_numbers"],
 478        )
 479        with patch(
 480            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 481            return_value=fake,
 482        ):
 483            result = await tool.run(None, x="test")
 484        assert not result.is_error
 485        assert result.output == "got: 42"
 486
 487    @pytest.mark.asyncio
 488    async def test_nested_tool_is_error(self, tmp_path):
 489        fake = FakeTool(
 490            "kiln_tool::add_numbers",
 491            "fake_add",
 492            params=EMPTY_SCHEMA,
 493            result=ToolCallResult(
 494                output="tool failed", is_error=True, error_message="tool failed"
 495            ),
 496        )
 497        code = textwrap.dedent("""\
 498            from kiln.tools import ToolCallError
 499            from kiln import tools
 500            def run(x):
 501                try:
 502                    tools.fake_add()
 503                except ToolCallError as e:
 504                    return f"error: {e.message}"
 505                return "no error"
 506        """)
 507        tool = _make_python_code_tool(
 508            tmp_path,
 509            code,
 510            tool_allowlist=["kiln_tool::add_numbers"],
 511        )
 512        with patch(
 513            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 514            return_value=fake,
 515        ):
 516            result = await tool.run(None, x="test")
 517        assert not result.is_error
 518        assert "error: tool failed" in result.output
 519
 520    @pytest.mark.asyncio
 521    async def test_nested_tool_not_allowed(self, tmp_path):
 522        fake = FakeTool(
 523            "kiln_tool::add_numbers",
 524            "fake_add",
 525            params=EMPTY_SCHEMA,
 526        )
 527        code = textwrap.dedent("""\
 528            from kiln.tools import ToolNotAllowed
 529            from kiln import tools
 530            def run(x):
 531                try:
 532                    tools.nonexistent_tool()
 533                except ToolNotAllowed as e:
 534                    return f"not allowed: {e.tool}"
 535                return "no error"
 536        """)
 537        tool = _make_python_code_tool(
 538            tmp_path,
 539            code,
 540            tool_allowlist=["kiln_tool::add_numbers"],
 541        )
 542        with patch(
 543            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 544            return_value=fake,
 545        ):
 546            result = await tool.run(None, x="test")
 547        assert not result.is_error
 548        assert "not allowed: nonexistent_tool" in result.output
 549
 550    @pytest.mark.asyncio
 551    async def test_nested_tool_ambiguous(self, tmp_path):
 552        fake1 = FakeTool("mcp::remote::server1::search", "search")
 553        fake2 = FakeTool("mcp::remote::server2::search", "search")
 554        fakes = {
 555            "mcp::remote::server1::search": fake1,
 556            "mcp::remote::server2::search": fake2,
 557        }
 558        code = textwrap.dedent("""\
 559            from kiln.tools import ToolCallError
 560            from kiln import tools
 561            def run(x):
 562                try:
 563                    tools.search()
 564                except ToolCallError as e:
 565                    return f"ambiguous: {e.message}"
 566                return "no error"
 567        """)
 568        tool = _make_python_code_tool(
 569            tmp_path,
 570            code,
 571            tool_allowlist=[
 572                "mcp::remote::server1::search",
 573                "mcp::remote::server2::search",
 574            ],
 575        )
 576        with patch(
 577            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 578            side_effect=lambda tid, **kw: fakes[tid],
 579        ):
 580            result = await tool.run(None, x="test")
 581        assert not result.is_error
 582        assert "ambiguous" in result.output.lower()
 583
 584    @pytest.mark.asyncio
 585    async def test_nested_tool_invalid_kwargs(self, tmp_path):
 586        fake = FakeTool(
 587            "kiln_tool::add_numbers",
 588            "fake_add",
 589            params={
 590                "type": "object",
 591                "properties": {"a": {"type": "integer"}},
 592                "required": ["a"],
 593            },
 594            result=ToolCallResult(output="42"),
 595        )
 596        code = textwrap.dedent("""\
 597            from kiln.tools import ToolCallError
 598            from kiln import tools
 599            def run(x):
 600                try:
 601                    tools.fake_add(a="not_an_int")
 602                except ToolCallError as e:
 603                    return f"invalid: {e.tool}"
 604                return "no error"
 605        """)
 606        tool = _make_python_code_tool(
 607            tmp_path,
 608            code,
 609            tool_allowlist=["kiln_tool::add_numbers"],
 610        )
 611        with patch(
 612            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 613            return_value=fake,
 614        ):
 615            result = await tool.run(None, x="test")
 616        assert not result.is_error
 617        assert "invalid: fake_add" in result.output
 618
 619
 620class TestListTools:
 621    @pytest.mark.asyncio
 622    async def test_list_tools_returns_content(self, tmp_path):
 623        fake = FakeTool(
 624            "kiln_tool::add_numbers",
 625            "fake_add",
 626            fn_desc="Add two numbers",
 627            params={
 628                "type": "object",
 629                "properties": {"a": {"type": "integer"}},
 630            },
 631        )
 632        code = textwrap.dedent("""\
 633            import json
 634            from kiln import tools
 635            def run(x):
 636                tool_list = tools.list_tools()
 637                return json.dumps(tool_list)
 638        """)
 639        tool = _make_python_code_tool(
 640            tmp_path,
 641            code,
 642            tool_allowlist=["kiln_tool::add_numbers"],
 643        )
 644        with patch(
 645            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 646            return_value=fake,
 647        ):
 648            result = await tool.run(None, x="test")
 649        assert not result.is_error
 650        tool_list = json.loads(result.output)
 651        assert len(tool_list) == 1
 652        assert tool_list[0]["name"] == "fake_add"
 653        assert tool_list[0]["description"] == "Add two numbers"
 654
 655
 656class TestTimeout:
 657    @pytest.mark.asyncio
 658    async def test_timeout_kills_child(self, tmp_path):
 659        tool = _make_python_code_tool(
 660            tmp_path,
 661            "import time\ndef run(x):\n    time.sleep(30)\n    return 'done'\n",
 662            timeout_seconds=1,
 663        )
 664        result = await tool.run(None, x="test")
 665        assert result.is_error
 666        assert "timed out" in result.output
 667
 668    @pytest.mark.asyncio
 669    async def test_timeout_during_nested_call(self, tmp_path):
 670        slow_fake = FakeTool(
 671            "kiln_tool::add_numbers",
 672            "fake_add",
 673            params=EMPTY_SCHEMA,
 674            result=ToolCallResult(output="42"),
 675            delay=30,
 676        )
 677        code = textwrap.dedent("""\
 678            from kiln import tools
 679            def run(x):
 680                return tools.fake_add()
 681        """)
 682        tool = _make_python_code_tool(
 683            tmp_path,
 684            code,
 685            tool_allowlist=["kiln_tool::add_numbers"],
 686            timeout_seconds=1,
 687        )
 688        with patch(
 689            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 690            return_value=slow_fake,
 691        ):
 692            result = await tool.run(None, x="test")
 693        assert result.is_error
 694        assert "timed out" in result.output
 695
 696
 697class TestCrash:
 698    @pytest.mark.asyncio
 699    async def test_crash_via_os_exit(self, tmp_path):
 700        tool = _make_python_code_tool(
 701            tmp_path,
 702            "import os\ndef run(x):\n    os._exit(3)\n",
 703        )
 704        result = await tool.run(None, x="test")
 705        assert result.is_error
 706        assert "crashed" in result.output
 707        assert "exit code" in result.output
 708
 709
 710class TestDepthCap:
 711    @pytest.mark.asyncio
 712    async def test_depth_cap_at_10(self, tmp_path):
 713        """Depth >= 10 returns an error without spawning."""
 714        tool = _make_python_code_tool(tmp_path, 'def run(x):\n    return "ok"\n')
 715        token = _depth.set(10)
 716        try:
 717            result = await tool.run(None, x="test")
 718        finally:
 719            _depth.reset(token)
 720        assert result.is_error
 721        assert "maximum nested code execution depth exceeded" in result.output
 722
 723
 724class TestSemaphore:
 725    @pytest.mark.asyncio
 726    async def test_semaphore_top_level_only_no_deadlock(self, tmp_path):
 727        """Regression: nested code-tool calls bypass the semaphore.
 728
 729        If nested calls counted against the semaphore, 8 parents each
 730        spawning a nested code-tool child would deadlock (parents hold
 731        all 8 slots, children wait forever).
 732
 733        This test sets MAX_CONCURRENCY parents running concurrently,
 734        each at depth 1 (simulating nested calls). All should complete
 735        without deadlock because nested calls bypass the semaphore.
 736        """
 737        code = 'def run(x):\n    return "nested_ok"\n'
 738        results = []
 739
 740        async def run_nested(i: int):
 741            tool = _make_python_code_tool(tmp_path, code)
 742            token = _depth.set(1)
 743            try:
 744                r = await tool.run(None, x=str(i))
 745                results.append(r)
 746            finally:
 747                _depth.reset(token)
 748
 749        await asyncio.gather(
 750            *(run_nested(i) for i in range(CODE_SANDBOX_MAX_CONCURRENCY))
 751        )
 752        assert len(results) == CODE_SANDBOX_MAX_CONCURRENCY
 753        assert all(not r.is_error for r in results)
 754
 755
 756class TestToolCallRecorder:
 757    @pytest.mark.asyncio
 758    async def test_recorder_gets_entries(self, tmp_path):
 759        fake = FakeTool(
 760            "kiln_tool::add_numbers",
 761            "fake_add",
 762            params=EMPTY_SCHEMA,
 763            result=ToolCallResult(output="42"),
 764        )
 765        log: list[ToolCallLogEntry] = []
 766        code = textwrap.dedent("""\
 767            from kiln import tools
 768            def run(x):
 769                r = tools.fake_add()
 770                return r
 771        """)
 772        tool = _make_python_code_tool(
 773            tmp_path,
 774            code,
 775            tool_allowlist=["kiln_tool::add_numbers"],
 776            tool_call_recorder=log.append,
 777        )
 778        with patch(
 779            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 780            return_value=fake,
 781        ):
 782            result = await tool.run(None, x="test")
 783        assert not result.is_error
 784        assert len(log) == 1
 785        assert log[0].tool_name == "fake_add"
 786        assert not log[0].is_error
 787        assert log[0].output_preview == "42"
 788        assert log[0].duration_ms >= 0
 789
 790
 791class TestAsyncToolsConcurrency:
 792    @pytest.mark.asyncio
 793    async def test_async_tools_gather_truly_concurrent(self, tmp_path):
 794        """async_tools + gather provides real parallelism via to_thread.
 795
 796        Two fake tools each take ~0.3s. If sequential, wall clock >= 0.6s.
 797        With true concurrency via gather + to_thread, wall clock < 0.6s.
 798        """
 799        slow_fake = FakeTool(
 800            "kiln_tool::add_numbers",
 801            "fake_add",
 802            params=EMPTY_SCHEMA,
 803            result=ToolCallResult(output="done"),
 804            delay=0.3,
 805        )
 806        code = textwrap.dedent("""\
 807            import asyncio, time
 808            from kiln import async_tools
 809            async def run(x):
 810                start = time.monotonic()
 811                a, b = await asyncio.gather(
 812                    async_tools.fake_add(),
 813                    async_tools.fake_add(),
 814                )
 815                elapsed = time.monotonic() - start
 816                return f"{elapsed:.2f}"
 817        """)
 818        tool = _make_python_code_tool(
 819            tmp_path,
 820            code,
 821            tool_allowlist=["kiln_tool::add_numbers"],
 822        )
 823        with patch(
 824            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 825            return_value=slow_fake,
 826        ):
 827            result = await tool.run(None, x="test")
 828        assert not result.is_error
 829        elapsed = float(result.output)
 830        assert elapsed < 0.6, f"Expected < 0.6s (concurrent), got {elapsed:.2f}s"
 831
 832
 833class _EchoFakeTool(KilnToolInterface):
 834    """Fake tool that echoes its kwargs back, proving per-call routing."""
 835
 836    def __init__(self, tool_id: str, fn_name: str, params: dict):
 837        self._id = tool_id
 838        self._name = fn_name
 839        self._params = params
 840
 841    async def id(self):
 842        return self._id
 843
 844    async def name(self):
 845        return self._name
 846
 847    async def description(self):
 848        return "echo"
 849
 850    async def toolcall_definition(self) -> ToolCallDefinition:
 851        return {
 852            "type": "function",
 853            "function": {
 854                "name": self._name,
 855                "description": "echo",
 856                "parameters": self._params,
 857            },
 858        }
 859
 860    async def run(self, context=None, **kwargs) -> ToolCallResult:
 861        return ToolCallResult(output=json.dumps(kwargs, sort_keys=True))
 862
 863
 864class TestCallIdRouting:
 865    @pytest.mark.asyncio
 866    async def test_concurrent_calls_routed_to_correct_caller(self, tmp_path):
 867        """4 threads each pass a unique idx kwarg; each gets its own value back.
 868
 869        The echo tool returns the kwargs it received. Each thread asserts it
 870        got back the idx it sent, proving call_id routing maps the right
 871        response to the right waiting caller under concurrency.
 872        """
 873        idx_schema = {
 874            "type": "object",
 875            "properties": {"idx": {"type": "string"}},
 876            "required": ["idx"],
 877        }
 878        code = textwrap.dedent("""\
 879            import json, threading
 880            from kiln import tools
 881            def run(x):
 882                results = [None] * 4
 883                errors = []
 884                def call_tool(i):
 885                    try:
 886                        raw = tools.fake_echo(idx=str(i))
 887                        results[i] = json.loads(raw)["idx"]
 888                    except Exception as e:
 889                        errors.append(f"thread {i}: {e}")
 890                threads = [threading.Thread(target=call_tool, args=(i,)) for i in range(4)]
 891                for t in threads:
 892                    t.start()
 893                for t in threads:
 894                    t.join()
 895                if errors:
 896                    return "errors: " + str(errors)
 897                return ",".join(results)
 898        """)
 899        fake = _EchoFakeTool(
 900            "kiln_tool::add_numbers",
 901            "fake_echo",
 902            params=idx_schema,
 903        )
 904        tool = _make_python_code_tool(
 905            tmp_path,
 906            code,
 907            tool_allowlist=["kiln_tool::add_numbers"],
 908        )
 909        with patch(
 910            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 911            return_value=fake,
 912        ):
 913            result = await tool.run(None, x="test")
 914        assert not result.is_error
 915        parts = result.output.split(",")
 916        assert len(parts) == 4
 917        assert parts == ["0", "1", "2", "3"]
 918
 919
 920class TestUnicodePassthrough:
 921    @pytest.mark.asyncio
 922    async def test_unicode_not_escaped(self, tmp_path):
 923        """ensure_ascii=False: non-ASCII chars pass through un-escaped."""
 924        tool = _make_python_code_tool(
 925            tmp_path,
 926            'def run(x):\n    return {"name": "\\u65e5\\u672c\\u8a9e"}\n',
 927        )
 928        result = await tool.run(None, x="test")
 929        assert not result.is_error
 930        parsed = json.loads(result.output)
 931        assert parsed["name"] == "日本語"
 932        assert "\\u" not in result.output
 933
 934
 935class TestSpawnLockIdentity:
 936    def test_spawn_lock_shared(self):
 937        """Code tools and code evals spawn through the same bridge / _spawn_lock."""
 938        from kiln_ai.tools import sandbox_bridge
 939
 940        assert (
 941            sandbox_bridge.start_process_with_light_main.__module__
 942            == "kiln_ai.sandbox.spawn"
 943        )
 944        from kiln_ai.sandbox.spawn import _spawn_lock as shared_lock
 945
 946        assert shared_lock is _spawn_lock
 947
 948
 949# ---------------------------------------------------------------------------
 950# End-to-end tests using REAL built-in tools (no mocking)
 951# ---------------------------------------------------------------------------
 952
 953
 954class TestRealBuiltInTools:
 955    """Tests that exercise real built-in tool dispatch without mocking
 956    ``tool_from_id_and_project``, ensuring the name-derivation path is
 957    exercised end-to-end.
 958    """
 959
 960    @pytest.mark.asyncio
 961    async def test_keyword_call_by_canonical_name_succeeds(self, tmp_path):
 962        """tools.add(a=1, b=2) returns '3' — the canonical name from list_tools."""
 963        code = textwrap.dedent("""\
 964            from kiln import tools
 965            def run(x):
 966                return tools.add(a=1, b=2)
 967        """)
 968        tool = _make_python_code_tool(
 969            tmp_path,
 970            code,
 971            tool_allowlist=["kiln_tool::add_numbers"],
 972        )
 973        result = await tool.run(None, x="test")
 974        assert not result.is_error, f"Expected success, got: {result.output}"
 975        assert result.output == "3"
 976
 977    @pytest.mark.asyncio
 978    async def test_list_tools_driven_call_succeeds(self, tmp_path):
 979        """Call using the name returned by list_tools() succeeds."""
 980        code = textwrap.dedent("""\
 981            from kiln import tools
 982            def run(x):
 983                tl = tools.list_tools()
 984                fn_name = tl[0]["name"]
 985                result = getattr(tools, fn_name)(a=5, b=3)
 986                return fn_name + ":" + result
 987        """)
 988        tool = _make_python_code_tool(
 989            tmp_path,
 990            code,
 991            tool_allowlist=["kiln_tool::add_numbers"],
 992        )
 993        result = await tool.run(None, x="test")
 994        assert not result.is_error, f"Expected success, got: {result.output}"
 995        assert result.output == "add:8"
 996
 997    @pytest.mark.asyncio
 998    async def test_friendly_name_not_allowed(self, tmp_path):
 999        """tools.Addition(a=1,b=2) raises ToolNotAllowed listing canonical names."""
1000        code = textwrap.dedent("""\
1001            from kiln.tools import ToolNotAllowed
1002            from kiln import tools
1003            def run(x):
1004                try:
1005                    tools.Addition(a=1, b=2)
1006                except ToolNotAllowed as e:
1007                    return e.message
1008                return "no error"
1009        """)
1010        tool = _make_python_code_tool(
1011            tmp_path,
1012            code,
1013            tool_allowlist=["kiln_tool::add_numbers"],
1014        )
1015        result = await tool.run(None, x="test")
1016        assert not result.is_error
1017        assert "not available" in result.output
1018        assert "'add'" in result.output
1019
1020    @pytest.mark.asyncio
1021    async def test_nonsense_name_not_allowed(self, tmp_path):
1022        """tools.bad_tool() raises ToolNotAllowed listing available names."""
1023        code = textwrap.dedent("""\
1024            from kiln.tools import ToolNotAllowed
1025            from kiln import tools
1026            def run(x):
1027                try:
1028                    tools.bad_tool(a=1)
1029                except ToolNotAllowed as e:
1030                    return e.message
1031                return "no error"
1032        """)
1033        tool = _make_python_code_tool(
1034            tmp_path,
1035            code,
1036            tool_allowlist=["kiln_tool::add_numbers"],
1037        )
1038        result = await tool.run(None, x="test")
1039        assert not result.is_error
1040        assert "not available" in result.output
1041        assert "'add'" in result.output
1042
1043    @pytest.mark.asyncio
1044    async def test_positional_args_error_message(self, tmp_path):
1045        """tools.add(1, 2) raises ToolCallError mentioning keyword args and params."""
1046        code = textwrap.dedent("""\
1047            from kiln.tools import ToolCallError
1048            from kiln import tools
1049            def run(x):
1050                try:
1051                    tools.add(1, 2)
1052                except ToolCallError as e:
1053                    return e.message
1054                return "no error"
1055        """)
1056        tool = _make_python_code_tool(
1057            tmp_path,
1058            code,
1059            tool_allowlist=["kiln_tool::add_numbers"],
1060        )
1061        result = await tool.run(None, x="test")
1062        assert not result.is_error
1063        assert "keyword arguments" in result.output
1064        assert "tools.add(" in result.output
1065        assert "a: number (required)" in result.output
1066        assert "b: number (required)" in result.output
1067
1068    @pytest.mark.asyncio
1069    async def test_wrong_kwargs_error_shows_schema(self, tmp_path):
1070        """tools.add(x=1) raises ToolCallError showing expected parameters."""
1071        code = textwrap.dedent("""\
1072            from kiln.tools import ToolCallError
1073            from kiln import tools
1074            def run(x):
1075                try:
1076                    tools.add(x=1)
1077                except ToolCallError as e:
1078                    return e.message
1079                return "no error"
1080        """)
1081        tool = _make_python_code_tool(
1082            tmp_path,
1083            code,
1084            tool_allowlist=["kiln_tool::add_numbers"],
1085        )
1086        result = await tool.run(None, x="test")
1087        assert not result.is_error
1088        assert "Expected parameters:" in result.output
1089        assert "a: number (required)" in result.output
1090
1091    @pytest.mark.asyncio
1092    async def test_name_consistency_across_all_builtins(self, tmp_path):
1093        """For every KilnBuiltInToolId the dispatch-map name matches tool.name()
1094        AND matches what list_tools reports."""
1095        from kiln_ai.datamodel.tool_id import KilnBuiltInToolId
1096        from kiln_ai.tools.tool_registry import tool_from_id_and_project
1097
1098        project = _make_project(tmp_path)
1099
1100        math_ids = [
1101            KilnBuiltInToolId.ADD_NUMBERS,
1102            KilnBuiltInToolId.SUBTRACT_NUMBERS,
1103            KilnBuiltInToolId.MULTIPLY_NUMBERS,
1104            KilnBuiltInToolId.DIVIDE_NUMBERS,
1105        ]
1106
1107        for builtin_id in math_ids:
1108            tool_id = builtin_id.value
1109            real_tool = tool_from_id_and_project(tool_id, project=project)
1110            real_name = await real_tool.name()
1111
1112            ct = _make_code_tool(
1113                'def run(x): return "ok"',
1114                tool_allowlist=[tool_id],
1115            )
1116            ct.parent = project
1117            server = NestedToolServer(
1118                allowlist=ct.tool_allowlist, project=project, task=None, context=None
1119            )
1120            dispatch_names = list((await server.name_map()).keys())
1121
1122            assert dispatch_names == [real_name], (
1123                f"For {builtin_id}: dispatch name {dispatch_names} != "
1124                f"tool.name() '{real_name}'"
1125            )
1126
1127    @pytest.mark.asyncio
1128    async def test_async_proxy_keyword_call(self, tmp_path):
1129        """async_tools.subtract(a=5, b=3) returns '2'."""
1130        code = textwrap.dedent("""\
1131            from kiln import async_tools
1132            async def run(x):
1133                return await async_tools.subtract(a=5, b=3)
1134        """)
1135        tool = _make_python_code_tool(
1136            tmp_path,
1137            code,
1138            tool_allowlist=["kiln_tool::subtract_numbers"],
1139        )
1140        result = await tool.run(None, x="test")
1141        assert not result.is_error, f"Expected success, got: {result.output}"
1142        assert result.output == "2"
1143
1144    @pytest.mark.asyncio
1145    async def test_async_proxy_positional_error(self, tmp_path):
1146        """async_tools.add(1, 2) raises ToolCallError with a helpful message."""
1147        code = textwrap.dedent("""\
1148            from kiln.tools import ToolCallError
1149            from kiln import async_tools
1150            async def run(x):
1151                try:
1152                    await async_tools.add(1, 2)
1153                except ToolCallError as e:
1154                    return e.message
1155                return "no error"
1156        """)
1157        tool = _make_python_code_tool(
1158            tmp_path,
1159            code,
1160            tool_allowlist=["kiln_tool::add_numbers"],
1161        )
1162        result = await tool.run(None, x="test")
1163        assert not result.is_error
1164        assert "keyword arguments" in result.output
1165
1166    @pytest.mark.asyncio
1167    async def test_positional_on_nonsense_name_still_not_allowed(self, tmp_path):
1168        """tools.bad_tool(1) raises ToolNotAllowed (not TypeError), regardless of args."""
1169        code = textwrap.dedent("""\
1170            from kiln.tools import ToolNotAllowed
1171            from kiln import tools
1172            def run(x):
1173                try:
1174                    tools.bad_tool(1, 2)
1175                except ToolNotAllowed as e:
1176                    return e.message
1177                return "no error"
1178        """)
1179        tool = _make_python_code_tool(
1180            tmp_path,
1181            code,
1182            tool_allowlist=["kiln_tool::add_numbers"],
1183        )
1184        result = await tool.run(None, x="test")
1185        assert not result.is_error
1186        assert "not available" in result.output
1187
1188
1189# ---------------------------------------------------------------------------
1190# UI example validation tests
1191#
1192# These tests execute the EXACT code strings shown in the "Code Tool Examples"
1193# modal (app/web_ui/src/lib/utils/code_tool_helpers.ts → generateExamples()).
1194# If you change those examples, you MUST update these tests to match.
1195# ---------------------------------------------------------------------------
1196
1197# The example code strings are duplicated here intentionally so any drift
1198# between the UI and these tests causes a test failure during review.
1199
1200EXAMPLE_PARALLEL_WITH_RETRIES = """\
1201import json
1202import time
1203from concurrent.futures import ThreadPoolExecutor, as_completed
1204from kiln import tools
1205
1206def run(urls: list[str], max_retries: int = 3) -> str:
1207    \"\"\"Fetch multiple URLs in parallel with retries.\"\"\"
1208    results = {}
1209
1210    def fetch_with_retry(url):
1211        for attempt in range(max_retries):
1212            try:
1213                result = tools.fetch_url(url=url)
1214                return url, json.loads(result)
1215            except Exception as e:
1216                if attempt == max_retries - 1:
1217                    return url, {"error": str(e)}
1218                time.sleep(0.5 * (attempt + 1))
1219
1220    with ThreadPoolExecutor(max_workers=5) as pool:
1221        futures = [pool.submit(fetch_with_retry, u) for u in urls]
1222        for future in as_completed(futures):
1223            url, data = future.result()
1224            results[url] = data
1225
1226    return json.dumps(results)
1227"""
1228
1229EXAMPLE_ASYNC_FAN_OUT = """\
1230import json
1231import asyncio
1232from kiln import async_tools
1233
1234async def run(user_ids: list[str]) -> str:
1235    \"\"\"Fetch user details concurrently using async_tools.\"\"\"
1236    async def fetch_user(uid):
1237        result = await async_tools.get_user(id=uid)
1238        return json.loads(result)
1239
1240    users = await asyncio.gather(*(fetch_user(uid) for uid in user_ids))
1241    return json.dumps(users)
1242"""
1243
1244EXAMPLE_FILTER_AND_TRANSFORM = """\
1245import json
1246from kiln import tools
1247
1248def run(query: str, max_results: int = 10) -> str:
1249    \"\"\"Search and filter results, returning only relevant fields.\"\"\"
1250    raw = tools.search(query=query)
1251    results = json.loads(raw)
1252
1253    filtered = [
1254        {"title": r["title"], "url": r["url"]}
1255        for r in results[:max_results]
1256        if "title" in r and "url" in r
1257    ]
1258
1259    return json.dumps(filtered)
1260"""
1261
1262
1263class TestUIExampleParallelWithRetries:
1264    """Validate the 'Parallel with Retries' example from the Code Tool Examples modal."""
1265
1266    @pytest.mark.asyncio
1267    async def test_parallel_with_retries_happy_path(self, tmp_path):
1268        fetch_url_responses = {
1269            "https://a.com": '{"status": "ok_a"}',
1270            "https://b.com": '{"status": "ok_b"}',
1271        }
1272        fake = FakeTool(
1273            "mcp::remote::test_server::fetch_url",
1274            "fetch_url",
1275            fn_desc="Fetch a URL",
1276            params={
1277                "type": "object",
1278                "properties": {"url": {"type": "string"}},
1279                "required": ["url"],
1280            },
1281        )
1282
1283        async def route_fetch(context=None, **kwargs):
1284            url = kwargs["url"]
1285            return ToolCallResult(output=fetch_url_responses[url])
1286
1287        fake.run = route_fetch  # type: ignore[assignment]
1288
1289        tool = _make_python_code_tool(
1290            tmp_path,
1291            EXAMPLE_PARALLEL_WITH_RETRIES,
1292            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1293            parameters_schema={
1294                "type": "object",
1295                "properties": {
1296                    "urls": {"type": "array", "items": {"type": "string"}},
1297                    "max_retries": {"type": "integer"},
1298                },
1299                "required": ["urls"],
1300            },
1301        )
1302        with patch(
1303            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1304            return_value=fake,
1305        ):
1306            result = await tool.run(
1307                None, urls=["https://a.com", "https://b.com"], max_retries=1
1308            )
1309        assert not result.is_error, f"Expected success, got: {result.output}"
1310        parsed = json.loads(result.output)
1311        assert parsed["https://a.com"] == {"status": "ok_a"}
1312        assert parsed["https://b.com"] == {"status": "ok_b"}
1313
1314    @pytest.mark.asyncio
1315    async def test_parallel_with_retries_error_fallback(self, tmp_path):
1316        """When a tool call fails, the retry logic catches the exception and
1317        returns an error dict after exhausting retries."""
1318        fake = FakeTool(
1319            "mcp::remote::test_server::fetch_url",
1320            "fetch_url",
1321            fn_desc="Fetch a URL",
1322            params={
1323                "type": "object",
1324                "properties": {"url": {"type": "string"}},
1325                "required": ["url"],
1326            },
1327            result=ToolCallResult(
1328                output="connection refused",
1329                is_error=True,
1330                error_message="connection refused",
1331            ),
1332        )
1333        tool = _make_python_code_tool(
1334            tmp_path,
1335            EXAMPLE_PARALLEL_WITH_RETRIES,
1336            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1337            parameters_schema={
1338                "type": "object",
1339                "properties": {
1340                    "urls": {"type": "array", "items": {"type": "string"}},
1341                    "max_retries": {"type": "integer"},
1342                },
1343                "required": ["urls"],
1344            },
1345        )
1346        with patch(
1347            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1348            return_value=fake,
1349        ):
1350            result = await tool.run(None, urls=["https://fail.com"], max_retries=1)
1351        assert not result.is_error, f"Expected success, got: {result.output}"
1352        parsed = json.loads(result.output)
1353        assert "error" in parsed["https://fail.com"]
1354
1355
1356class TestUIExampleAsyncFanOut:
1357    """Validate the 'Async Fan-Out' example from the Code Tool Examples modal."""
1358
1359    @pytest.mark.asyncio
1360    async def test_async_fan_out_happy_path(self, tmp_path):
1361        user_data = {
1362            "u1": '{"name": "Alice", "id": "u1"}',
1363            "u2": '{"name": "Bob", "id": "u2"}',
1364        }
1365        fake = FakeTool(
1366            "mcp::remote::test_server::get_user",
1367            "get_user",
1368            fn_desc="Get user details",
1369            params={
1370                "type": "object",
1371                "properties": {"id": {"type": "string"}},
1372                "required": ["id"],
1373            },
1374        )
1375
1376        async def route_user(context=None, **kwargs):
1377            uid = kwargs["id"]
1378            return ToolCallResult(output=user_data[uid])
1379
1380        fake.run = route_user  # type: ignore[assignment]
1381
1382        tool = _make_python_code_tool(
1383            tmp_path,
1384            EXAMPLE_ASYNC_FAN_OUT,
1385            tool_allowlist=["mcp::remote::test_server::get_user"],
1386            parameters_schema={
1387                "type": "object",
1388                "properties": {
1389                    "user_ids": {"type": "array", "items": {"type": "string"}},
1390                },
1391                "required": ["user_ids"],
1392            },
1393        )
1394        with patch(
1395            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1396            return_value=fake,
1397        ):
1398            result = await tool.run(None, user_ids=["u1", "u2"])
1399        assert not result.is_error, f"Expected success, got: {result.output}"
1400        parsed = json.loads(result.output)
1401        assert len(parsed) == 2
1402        assert parsed[0] == {"name": "Alice", "id": "u1"}
1403        assert parsed[1] == {"name": "Bob", "id": "u2"}
1404
1405
1406class TestUIExampleFilterAndTransform:
1407    """Validate the 'Filter & Transform' example from the Code Tool Examples modal."""
1408
1409    @pytest.mark.asyncio
1410    async def test_filter_and_transform_happy_path(self, tmp_path):
1411        search_results = json.dumps(
1412            [
1413                {"title": "Result 1", "url": "https://1.com", "score": 0.9},
1414                {"title": "Result 2", "url": "https://2.com", "score": 0.8},
1415                {"description": "no title or url"},
1416                {"title": "Result 3", "url": "https://3.com", "score": 0.7},
1417            ]
1418        )
1419        fake = FakeTool(
1420            "mcp::remote::test_server::search",
1421            "search",
1422            fn_desc="Search",
1423            params={
1424                "type": "object",
1425                "properties": {"query": {"type": "string"}},
1426                "required": ["query"],
1427            },
1428            result=ToolCallResult(output=search_results),
1429        )
1430        tool = _make_python_code_tool(
1431            tmp_path,
1432            EXAMPLE_FILTER_AND_TRANSFORM,
1433            tool_allowlist=["mcp::remote::test_server::search"],
1434            parameters_schema={
1435                "type": "object",
1436                "properties": {
1437                    "query": {"type": "string"},
1438                    "max_results": {"type": "integer"},
1439                },
1440                "required": ["query"],
1441            },
1442        )
1443        with patch(
1444            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1445            return_value=fake,
1446        ):
1447            result = await tool.run(None, query="test query")
1448        assert not result.is_error, f"Expected success, got: {result.output}"
1449        parsed = json.loads(result.output)
1450        assert len(parsed) == 3
1451        assert parsed[0] == {"title": "Result 1", "url": "https://1.com"}
1452        assert parsed[1] == {"title": "Result 2", "url": "https://2.com"}
1453        assert parsed[2] == {"title": "Result 3", "url": "https://3.com"}
1454
1455    @pytest.mark.asyncio
1456    async def test_filter_and_transform_respects_max_results(self, tmp_path):
1457        search_results = json.dumps(
1458            [{"title": f"R{i}", "url": f"https://{i}.com"} for i in range(20)]
1459        )
1460        fake = FakeTool(
1461            "mcp::remote::test_server::search",
1462            "search",
1463            fn_desc="Search",
1464            params={
1465                "type": "object",
1466                "properties": {"query": {"type": "string"}},
1467                "required": ["query"],
1468            },
1469            result=ToolCallResult(output=search_results),
1470        )
1471        tool = _make_python_code_tool(
1472            tmp_path,
1473            EXAMPLE_FILTER_AND_TRANSFORM,
1474            tool_allowlist=["mcp::remote::test_server::search"],
1475            parameters_schema={
1476                "type": "object",
1477                "properties": {
1478                    "query": {"type": "string"},
1479                    "max_results": {"type": "integer"},
1480                },
1481                "required": ["query"],
1482            },
1483        )
1484        with patch(
1485            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1486            return_value=fake,
1487        ):
1488            result = await tool.run(None, query="test", max_results=3)
1489        assert not result.is_error, f"Expected success, got: {result.output}"
1490        parsed = json.loads(result.output)
1491        assert len(parsed) == 3
VALID_SCHEMA = {'type': 'object', 'properties': {'x': {'type': 'string'}}}
EMPTY_SCHEMA = {'type': 'object', 'properties': {}}
class FakeTool(kiln_ai.tools.base_tool.KilnToolInterface):
 84class FakeTool(KilnToolInterface):
 85    """Minimal tool double for testing nested calls.
 86
 87    IMPORTANT: ``fn_name`` intentionally DIFFERS from the tool_id slug
 88    (e.g. ``fn_name="fake_add"`` for ``tool_id="kiln_tool::add_numbers"``).
 89    This ensures tests catch name-derivation bugs where the dispatch map
 90    would use the slug instead of ``tool.name()``.
 91    """
 92
 93    def __init__(
 94        self,
 95        tool_id: str,
 96        fn_name: str,
 97        fn_desc: str = "fake",
 98        params: dict | None = None,
 99        result: ToolCallResult | None = None,
100        delay: float = 0,
101    ):
102        self._id = tool_id
103        self._name = fn_name
104        self._desc = fn_desc
105        self._params = params or EMPTY_SCHEMA
106        self._result = result or ToolCallResult(output="ok")
107        self._delay = delay
108
109    async def id(self):
110        return self._id
111
112    async def name(self):
113        return self._name
114
115    async def description(self):
116        return self._desc
117
118    async def toolcall_definition(self) -> ToolCallDefinition:
119        return {
120            "type": "function",
121            "function": {
122                "name": self._name,
123                "description": self._desc,
124                "parameters": self._params,
125            },
126        }
127
128    async def run(self, context=None, **kwargs) -> ToolCallResult:
129        if self._delay > 0:
130            await asyncio.sleep(self._delay)
131        return self._result

Minimal tool double for testing nested calls.

IMPORTANT: fn_name intentionally DIFFERS from the tool_id slug (e.g. fn_name="fake_add" for tool_id="kiln_tool::add_numbers"). This ensures tests catch name-derivation bugs where the dispatch map would use the slug instead of tool.name().

FakeTool( tool_id: str, fn_name: str, fn_desc: str = 'fake', params: dict | None = None, result: kiln_ai.tools.base_tool.ToolCallResult | None = None, delay: float = 0)
 93    def __init__(
 94        self,
 95        tool_id: str,
 96        fn_name: str,
 97        fn_desc: str = "fake",
 98        params: dict | None = None,
 99        result: ToolCallResult | None = None,
100        delay: float = 0,
101    ):
102        self._id = tool_id
103        self._name = fn_name
104        self._desc = fn_desc
105        self._params = params or EMPTY_SCHEMA
106        self._result = result or ToolCallResult(output="ok")
107        self._delay = delay
async def id(self):
109    async def id(self):
110        return self._id

Return a unique identifier for this tool.

async def name(self):
112    async def name(self):
113        return self._name

Return the tool name (function name) of this tool.

async def description(self):
115    async def description(self):
116        return self._desc

Return a description of what this tool does.

async def toolcall_definition(self) -> kiln_ai.tools.base_tool.ToolCallDefinition:
118    async def toolcall_definition(self) -> ToolCallDefinition:
119        return {
120            "type": "function",
121            "function": {
122                "name": self._name,
123                "description": self._desc,
124                "parameters": self._params,
125            },
126        }

Return the OpenAI-compatible tool definition for this tool.

async def run(self, context=None, **kwargs) -> kiln_ai.tools.base_tool.ToolCallResult:
128    async def run(self, context=None, **kwargs) -> ToolCallResult:
129        if self._delay > 0:
130            await asyncio.sleep(self._delay)
131        return self._result

Execute the tool with the given parameters and calling context if provided.

class TestChildSyncRun:
139class TestChildSyncRun:
140    @pytest.mark.asyncio
141    async def test_sync_run_returns_string(self, tmp_path):
142        tool = _make_python_code_tool(
143            tmp_path,
144            'def run(x):\n    return "hello " + x\n',
145        )
146        result = await tool.run(None, x="world")
147        assert not result.is_error
148        assert result.output == "hello world"
149
150    @pytest.mark.asyncio
151    async def test_sync_run_returns_dict(self, tmp_path):
152        tool = _make_python_code_tool(
153            tmp_path,
154            'def run(x):\n    return {"value": x}\n',
155        )
156        result = await tool.run(None, x="test")
157        assert not result.is_error
158        assert json.loads(result.output) == {"value": "test"}
159
160    @pytest.mark.asyncio
161    async def test_sync_run_returns_none(self, tmp_path):
162        tool = _make_python_code_tool(
163            tmp_path,
164            "def run(x):\n    pass\n",
165        )
166        result = await tool.run(None, x="test")
167        assert not result.is_error
168        assert result.output == "null"
@pytest.mark.asyncio
async def test_sync_run_returns_string(self, tmp_path):
140    @pytest.mark.asyncio
141    async def test_sync_run_returns_string(self, tmp_path):
142        tool = _make_python_code_tool(
143            tmp_path,
144            'def run(x):\n    return "hello " + x\n',
145        )
146        result = await tool.run(None, x="world")
147        assert not result.is_error
148        assert result.output == "hello world"
@pytest.mark.asyncio
async def test_sync_run_returns_dict(self, tmp_path):
150    @pytest.mark.asyncio
151    async def test_sync_run_returns_dict(self, tmp_path):
152        tool = _make_python_code_tool(
153            tmp_path,
154            'def run(x):\n    return {"value": x}\n',
155        )
156        result = await tool.run(None, x="test")
157        assert not result.is_error
158        assert json.loads(result.output) == {"value": "test"}
@pytest.mark.asyncio
async def test_sync_run_returns_none(self, tmp_path):
160    @pytest.mark.asyncio
161    async def test_sync_run_returns_none(self, tmp_path):
162        tool = _make_python_code_tool(
163            tmp_path,
164            "def run(x):\n    pass\n",
165        )
166        result = await tool.run(None, x="test")
167        assert not result.is_error
168        assert result.output == "null"
class TestChildAsyncRun:
171class TestChildAsyncRun:
172    @pytest.mark.asyncio
173    async def test_async_run_returns_string(self, tmp_path):
174        tool = _make_python_code_tool(
175            tmp_path,
176            textwrap.dedent("""\
177                import asyncio
178                async def run(x):
179                    async def greet(name):
180                        return "hi " + name
181                    results = await asyncio.gather(greet(x), greet(x + "!"))
182                    return " ".join(results)
183            """),
184        )
185        result = await tool.run(None, x="a")
186        assert not result.is_error
187        assert result.output == "hi a hi a!"
188
189    @pytest.mark.asyncio
190    async def test_asyncio_run_inside_async_errors(self, tmp_path):
191        tool = _make_python_code_tool(
192            tmp_path,
193            textwrap.dedent("""\
194                import asyncio
195                async def helper():
196                    return 1
197                async def run(x):
198                    return asyncio.run(helper())
199            """),
200        )
201        result = await tool.run(None, x="test")
202        assert result.is_error
203        assert (
204            "cannot be called from a running event loop" in result.output.lower()
205            or "cannot" in result.output.lower()
206        )
@pytest.mark.asyncio
async def test_async_run_returns_string(self, tmp_path):
172    @pytest.mark.asyncio
173    async def test_async_run_returns_string(self, tmp_path):
174        tool = _make_python_code_tool(
175            tmp_path,
176            textwrap.dedent("""\
177                import asyncio
178                async def run(x):
179                    async def greet(name):
180                        return "hi " + name
181                    results = await asyncio.gather(greet(x), greet(x + "!"))
182                    return " ".join(results)
183            """),
184        )
185        result = await tool.run(None, x="a")
186        assert not result.is_error
187        assert result.output == "hi a hi a!"
@pytest.mark.asyncio
async def test_asyncio_run_inside_async_errors(self, tmp_path):
189    @pytest.mark.asyncio
190    async def test_asyncio_run_inside_async_errors(self, tmp_path):
191        tool = _make_python_code_tool(
192            tmp_path,
193            textwrap.dedent("""\
194                import asyncio
195                async def helper():
196                    return 1
197                async def run(x):
198                    return asyncio.run(helper())
199            """),
200        )
201        result = await tool.run(None, x="test")
202        assert result.is_error
203        assert (
204            "cannot be called from a running event loop" in result.output.lower()
205            or "cannot" in result.output.lower()
206        )
class TestReturnSerialization:
209class TestReturnSerialization:
210    @pytest.mark.asyncio
211    @pytest.mark.parametrize(
212        "code,expected",
213        [
214            ('def run(x):\n    return "raw"\n', "raw"),
215            ("def run(x):\n    return 42\n", "42"),
216            ("def run(x):\n    return 3.14\n", "3.14"),
217            ("def run(x):\n    return True\n", "true"),
218            ("def run(x):\n    return False\n", "false"),
219            ("def run(x):\n    return None\n", "null"),
220            ("def run(x):\n    return [1, 2]\n", "[1, 2]"),
221            ('def run(x):\n    return {"k": "v"}\n', '{"k": "v"}'),
222        ],
223        ids=[
224            "str",
225            "int",
226            "float",
227            "bool_true",
228            "bool_false",
229            "none",
230            "list",
231            "dict",
232        ],
233    )
234    async def test_serialization(self, tmp_path, code, expected):
235        tool = _make_python_code_tool(tmp_path, code)
236        result = await tool.run(None, x="test")
237        assert not result.is_error
238        assert result.output == expected
239
240    @pytest.mark.asyncio
241    async def test_non_serializable_type_errors(self, tmp_path):
242        tool = _make_python_code_tool(
243            tmp_path,
244            "def run(x):\n    return object()\n",
245        )
246        result = await tool.run(None, x="test")
247        assert result.is_error
248        assert "must return str or JSON-serializable" in result.output
249
250    @pytest.mark.asyncio
251    async def test_non_serializable_nested_value_errors(self, tmp_path):
252        tool = _make_python_code_tool(
253            tmp_path,
254            "def run(x):\n    return {'fn': lambda: None}\n",
255        )
256        result = await tool.run(None, x="test")
257        assert result.is_error
258        assert "non-JSON-serializable" in result.output
259
260    @pytest.mark.asyncio
261    async def test_string_passthrough_no_parsing(self, tmp_path):
262        """JSON-shaped string returned by run() comes back as-is, not parsed."""
263        tool = _make_python_code_tool(
264            tmp_path,
265            'def run(x):\n    return \'{"key": "value"}\'\n',
266        )
267        result = await tool.run(None, x="test")
268        assert not result.is_error
269        assert result.output == '{"key": "value"}'
@pytest.mark.asyncio
@pytest.mark.parametrize('code,expected', [('def run(x):\n return "raw"\n', 'raw'), ('def run(x):\n return 42\n', '42'), ('def run(x):\n return 3.14\n', '3.14'), ('def run(x):\n return True\n', 'true'), ('def run(x):\n return False\n', 'false'), ('def run(x):\n return None\n', 'null'), ('def run(x):\n return [1, 2]\n', '[1, 2]'), ('def run(x):\n return {"k": "v"}\n', '{"k": "v"}')], ids=['str', 'int', 'float', 'bool_true', 'bool_false', 'none', 'list', 'dict'])
async def test_serialization(self, tmp_path, code, expected):
210    @pytest.mark.asyncio
211    @pytest.mark.parametrize(
212        "code,expected",
213        [
214            ('def run(x):\n    return "raw"\n', "raw"),
215            ("def run(x):\n    return 42\n", "42"),
216            ("def run(x):\n    return 3.14\n", "3.14"),
217            ("def run(x):\n    return True\n", "true"),
218            ("def run(x):\n    return False\n", "false"),
219            ("def run(x):\n    return None\n", "null"),
220            ("def run(x):\n    return [1, 2]\n", "[1, 2]"),
221            ('def run(x):\n    return {"k": "v"}\n', '{"k": "v"}'),
222        ],
223        ids=[
224            "str",
225            "int",
226            "float",
227            "bool_true",
228            "bool_false",
229            "none",
230            "list",
231            "dict",
232        ],
233    )
234    async def test_serialization(self, tmp_path, code, expected):
235        tool = _make_python_code_tool(tmp_path, code)
236        result = await tool.run(None, x="test")
237        assert not result.is_error
238        assert result.output == expected
@pytest.mark.asyncio
async def test_non_serializable_type_errors(self, tmp_path):
240    @pytest.mark.asyncio
241    async def test_non_serializable_type_errors(self, tmp_path):
242        tool = _make_python_code_tool(
243            tmp_path,
244            "def run(x):\n    return object()\n",
245        )
246        result = await tool.run(None, x="test")
247        assert result.is_error
248        assert "must return str or JSON-serializable" in result.output
@pytest.mark.asyncio
async def test_non_serializable_nested_value_errors(self, tmp_path):
250    @pytest.mark.asyncio
251    async def test_non_serializable_nested_value_errors(self, tmp_path):
252        tool = _make_python_code_tool(
253            tmp_path,
254            "def run(x):\n    return {'fn': lambda: None}\n",
255        )
256        result = await tool.run(None, x="test")
257        assert result.is_error
258        assert "non-JSON-serializable" in result.output
@pytest.mark.asyncio
async def test_string_passthrough_no_parsing(self, tmp_path):
260    @pytest.mark.asyncio
261    async def test_string_passthrough_no_parsing(self, tmp_path):
262        """JSON-shaped string returned by run() comes back as-is, not parsed."""
263        tool = _make_python_code_tool(
264            tmp_path,
265            'def run(x):\n    return \'{"key": "value"}\'\n',
266        )
267        result = await tool.run(None, x="test")
268        assert not result.is_error
269        assert result.output == '{"key": "value"}'

JSON-shaped string returned by run() comes back as-is, not parsed.

class TestStdoutStderr:
272class TestStdoutStderr:
273    @pytest.mark.asyncio
274    async def test_stdout_captured(self, tmp_path):
275        project = _make_project(tmp_path)
276        ct = _make_code_tool(
277            'import sys\ndef run(x):\n    sys.stdout.write("debug")\n    return "ok"\n',
278        )
279        ct.parent = project
280        pct = PythonCodeTool(ct, project)
281        outcome = await pct._invoke(None, {"x": "test"})
282        assert outcome.ok == "ok"
283        assert "debug" in outcome.stdout
284
285    @pytest.mark.asyncio
286    async def test_stdout_truncation(self, tmp_path):
287        project = _make_project(tmp_path)
288        ct = _make_code_tool(
289            'import sys\ndef run(x):\n    sys.stdout.write("A" * 100000)\n    return "ok"\n',
290        )
291        ct.parent = project
292        pct = PythonCodeTool(ct, project)
293        outcome = await pct._invoke(None, {"x": "test"})
294        assert outcome.ok == "ok"
295        assert len(outcome.stdout) <= 64 * 1024 + 50
296        assert "truncated" in outcome.stdout
@pytest.mark.asyncio
async def test_stdout_captured(self, tmp_path):
273    @pytest.mark.asyncio
274    async def test_stdout_captured(self, tmp_path):
275        project = _make_project(tmp_path)
276        ct = _make_code_tool(
277            'import sys\ndef run(x):\n    sys.stdout.write("debug")\n    return "ok"\n',
278        )
279        ct.parent = project
280        pct = PythonCodeTool(ct, project)
281        outcome = await pct._invoke(None, {"x": "test"})
282        assert outcome.ok == "ok"
283        assert "debug" in outcome.stdout
@pytest.mark.asyncio
async def test_stdout_truncation(self, tmp_path):
285    @pytest.mark.asyncio
286    async def test_stdout_truncation(self, tmp_path):
287        project = _make_project(tmp_path)
288        ct = _make_code_tool(
289            'import sys\ndef run(x):\n    sys.stdout.write("A" * 100000)\n    return "ok"\n',
290        )
291        ct.parent = project
292        pct = PythonCodeTool(ct, project)
293        outcome = await pct._invoke(None, {"x": "test"})
294        assert outcome.ok == "ok"
295        assert len(outcome.stdout) <= 64 * 1024 + 50
296        assert "truncated" in outcome.stdout
class TestTraceback:
299class TestTraceback:
300    @pytest.mark.asyncio
301    async def test_traceback_shows_code_tool_lines(self, tmp_path):
302        tool = _make_python_code_tool(
303            tmp_path,
304            textwrap.dedent("""\
305                def helper():
306                    raise ValueError("kaboom")
307                def run(x):
308                    helper()
309            """),
310        )
311        result = await tool.run(None, x="test")
312        assert result.is_error
313        assert "kaboom" in result.output
314        assert "<code_tool>" in result.output
315        assert "worker.py" not in result.output
@pytest.mark.asyncio
async def test_traceback_shows_code_tool_lines(self, tmp_path):
300    @pytest.mark.asyncio
301    async def test_traceback_shows_code_tool_lines(self, tmp_path):
302        tool = _make_python_code_tool(
303            tmp_path,
304            textwrap.dedent("""\
305                def helper():
306                    raise ValueError("kaboom")
307                def run(x):
308                    helper()
309            """),
310        )
311        result = await tool.run(None, x="test")
312        assert result.is_error
313        assert "kaboom" in result.output
314        assert "<code_tool>" in result.output
315        assert "worker.py" not in result.output
class TestMissingRun:
318class TestMissingRun:
319    @pytest.mark.asyncio
320    async def test_missing_run_defense(self, tmp_path):
321        """Even if save-time validation is bypassed, child handles missing run()."""
322        project = _make_project(tmp_path)
323        ct = CodeTool.__new__(CodeTool)
324        object.__setattr__(
325            ct,
326            "__dict__",
327            {
328                "name": "bad",
329                "tool_function_name": "bad",
330                "tool_description": "bad",
331                "parameters_schema": EMPTY_SCHEMA,
332                "code": "x = 1\n",
333                "timeout_seconds": 10,
334                "tool_allowlist": [],
335                "description": None,
336                "is_archived": False,
337                "id": "test123",
338                "v": 1,
339                "created_at": None,
340                "created_by": None,
341                "path": None,
342            },
343        )
344        object.__setattr__(ct, "__pydantic_fields_set__", set())
345        pct = PythonCodeTool(ct, project)
346        result = await pct.run(None)
347        assert result.is_error
348        assert "run" in result.output.lower()
@pytest.mark.asyncio
async def test_missing_run_defense(self, tmp_path):
319    @pytest.mark.asyncio
320    async def test_missing_run_defense(self, tmp_path):
321        """Even if save-time validation is bypassed, child handles missing run()."""
322        project = _make_project(tmp_path)
323        ct = CodeTool.__new__(CodeTool)
324        object.__setattr__(
325            ct,
326            "__dict__",
327            {
328                "name": "bad",
329                "tool_function_name": "bad",
330                "tool_description": "bad",
331                "parameters_schema": EMPTY_SCHEMA,
332                "code": "x = 1\n",
333                "timeout_seconds": 10,
334                "tool_allowlist": [],
335                "description": None,
336                "is_archived": False,
337                "id": "test123",
338                "v": 1,
339                "created_at": None,
340                "created_by": None,
341                "path": None,
342            },
343        )
344        object.__setattr__(ct, "__pydantic_fields_set__", set())
345        pct = PythonCodeTool(ct, project)
346        result = await pct.run(None)
347        assert result.is_error
348        assert "run" in result.output.lower()

Even if save-time validation is bypassed, child handles missing run().

class TestImportForms:
351class TestImportForms:
352    @pytest.mark.asyncio
353    async def test_from_kiln_import_tools(self, tmp_path):
354        tool = _make_python_code_tool(
355            tmp_path,
356            textwrap.dedent("""\
357                from kiln import tools
358                def run(x):
359                    return type(tools).__name__
360            """),
361        )
362        result = await tool.run(None, x="test")
363        assert not result.is_error
364
365    @pytest.mark.asyncio
366    async def test_import_kiln_tools(self, tmp_path):
367        tool = _make_python_code_tool(
368            tmp_path,
369            textwrap.dedent("""\
370                import kiln.tools
371                def run(x):
372                    return type(kiln.tools).__name__
373            """),
374        )
375        result = await tool.run(None, x="test")
376        assert not result.is_error
377
378    @pytest.mark.asyncio
379    async def test_from_kiln_tools_import_exception(self, tmp_path):
380        tool = _make_python_code_tool(
381            tmp_path,
382            textwrap.dedent("""\
383                from kiln.tools import ToolCallError
384                def run(x):
385                    return ToolCallError.__name__
386            """),
387        )
388        result = await tool.run(None, x="test")
389        assert not result.is_error
390        assert result.output == "ToolCallError"
391
392    @pytest.mark.asyncio
393    async def test_from_kiln_import_async_tools(self, tmp_path):
394        tool = _make_python_code_tool(
395            tmp_path,
396            textwrap.dedent("""\
397                from kiln import async_tools
398                def run(x):
399                    return type(async_tools).__name__
400            """),
401        )
402        result = await tool.run(None, x="test")
403        assert not result.is_error
404
405    @pytest.mark.asyncio
406    async def test_exception_classes_identical_across_modules(self, tmp_path):
407        tool = _make_python_code_tool(
408            tmp_path,
409            textwrap.dedent("""\
410                from kiln import tools, async_tools
411                def run(x):
412                    same_not_allowed = tools.ToolNotAllowed is async_tools.ToolNotAllowed
413                    same_timeout = tools.ToolTimeout is async_tools.ToolTimeout
414                    same_call_error = tools.ToolCallError is async_tools.ToolCallError
415                    return str(same_not_allowed and same_timeout and same_call_error)
416            """),
417        )
418        result = await tool.run(None, x="test")
419        assert not result.is_error
420        assert result.output == "True"
@pytest.mark.asyncio
async def test_from_kiln_import_tools(self, tmp_path):
352    @pytest.mark.asyncio
353    async def test_from_kiln_import_tools(self, tmp_path):
354        tool = _make_python_code_tool(
355            tmp_path,
356            textwrap.dedent("""\
357                from kiln import tools
358                def run(x):
359                    return type(tools).__name__
360            """),
361        )
362        result = await tool.run(None, x="test")
363        assert not result.is_error
@pytest.mark.asyncio
async def test_import_kiln_tools(self, tmp_path):
365    @pytest.mark.asyncio
366    async def test_import_kiln_tools(self, tmp_path):
367        tool = _make_python_code_tool(
368            tmp_path,
369            textwrap.dedent("""\
370                import kiln.tools
371                def run(x):
372                    return type(kiln.tools).__name__
373            """),
374        )
375        result = await tool.run(None, x="test")
376        assert not result.is_error
@pytest.mark.asyncio
async def test_from_kiln_tools_import_exception(self, tmp_path):
378    @pytest.mark.asyncio
379    async def test_from_kiln_tools_import_exception(self, tmp_path):
380        tool = _make_python_code_tool(
381            tmp_path,
382            textwrap.dedent("""\
383                from kiln.tools import ToolCallError
384                def run(x):
385                    return ToolCallError.__name__
386            """),
387        )
388        result = await tool.run(None, x="test")
389        assert not result.is_error
390        assert result.output == "ToolCallError"
@pytest.mark.asyncio
async def test_from_kiln_import_async_tools(self, tmp_path):
392    @pytest.mark.asyncio
393    async def test_from_kiln_import_async_tools(self, tmp_path):
394        tool = _make_python_code_tool(
395            tmp_path,
396            textwrap.dedent("""\
397                from kiln import async_tools
398                def run(x):
399                    return type(async_tools).__name__
400            """),
401        )
402        result = await tool.run(None, x="test")
403        assert not result.is_error
@pytest.mark.asyncio
async def test_exception_classes_identical_across_modules(self, tmp_path):
405    @pytest.mark.asyncio
406    async def test_exception_classes_identical_across_modules(self, tmp_path):
407        tool = _make_python_code_tool(
408            tmp_path,
409            textwrap.dedent("""\
410                from kiln import tools, async_tools
411                def run(x):
412                    same_not_allowed = tools.ToolNotAllowed is async_tools.ToolNotAllowed
413                    same_timeout = tools.ToolTimeout is async_tools.ToolTimeout
414                    same_call_error = tools.ToolCallError is async_tools.ToolCallError
415                    return str(same_not_allowed and same_timeout and same_call_error)
416            """),
417        )
418        result = await tool.run(None, x="test")
419        assert not result.is_error
420        assert result.output == "True"
class TestJsonUnsafeKwargs:
423class TestJsonUnsafeKwargs:
424    @pytest.mark.asyncio
425    async def test_json_unsafe_kwargs_raise_in_frame(self, tmp_path):
426        """Non-JSON-serializable tool kwargs raise ToolCallError inside child."""
427        code = textwrap.dedent("""\
428            from kiln.tools import ToolCallError
429            from kiln import tools
430            def run(x):
431                try:
432                    tools.some_tool(bad=object())
433                except ToolCallError as e:
434                    return f"caught: {e.tool}"
435                return "no error"
436        """)
437        tool = _make_python_code_tool(tmp_path, code)
438        result = await tool.run(None, x="test")
439        assert not result.is_error
440        assert "caught: some_tool" in result.output
@pytest.mark.asyncio
async def test_json_unsafe_kwargs_raise_in_frame(self, tmp_path):
424    @pytest.mark.asyncio
425    async def test_json_unsafe_kwargs_raise_in_frame(self, tmp_path):
426        """Non-JSON-serializable tool kwargs raise ToolCallError inside child."""
427        code = textwrap.dedent("""\
428            from kiln.tools import ToolCallError
429            from kiln import tools
430            def run(x):
431                try:
432                    tools.some_tool(bad=object())
433                except ToolCallError as e:
434                    return f"caught: {e.tool}"
435                return "no error"
436        """)
437        tool = _make_python_code_tool(tmp_path, code)
438        result = await tool.run(None, x="test")
439        assert not result.is_error
440        assert "caught: some_tool" in result.output

Non-JSON-serializable tool kwargs raise ToolCallError inside child.

class TestHappyPath:
448class TestHappyPath:
449    @pytest.mark.asyncio
450    async def test_simple_run(self, tmp_path):
451        tool = _make_python_code_tool(
452            tmp_path,
453            'def run(x):\n    return "result_" + x\n',
454        )
455        result = await tool.run(None, x="abc")
456        assert not result.is_error
457        assert result.output == "result_abc"
@pytest.mark.asyncio
async def test_simple_run(self, tmp_path):
449    @pytest.mark.asyncio
450    async def test_simple_run(self, tmp_path):
451        tool = _make_python_code_tool(
452            tmp_path,
453            'def run(x):\n    return "result_" + x\n',
454        )
455        result = await tool.run(None, x="abc")
456        assert not result.is_error
457        assert result.output == "result_abc"
class TestNestedToolCalls:
460class TestNestedToolCalls:
461    @pytest.mark.asyncio
462    async def test_nested_tool_success(self, tmp_path):
463        fake = FakeTool(
464            "kiln_tool::add_numbers",
465            "fake_add",
466            params=EMPTY_SCHEMA,
467            result=ToolCallResult(output="42"),
468        )
469        code = textwrap.dedent("""\
470            from kiln import tools
471            def run(x):
472                result = tools.fake_add()
473                return "got: " + result
474        """)
475        tool = _make_python_code_tool(
476            tmp_path,
477            code,
478            tool_allowlist=["kiln_tool::add_numbers"],
479        )
480        with patch(
481            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
482            return_value=fake,
483        ):
484            result = await tool.run(None, x="test")
485        assert not result.is_error
486        assert result.output == "got: 42"
487
488    @pytest.mark.asyncio
489    async def test_nested_tool_is_error(self, tmp_path):
490        fake = FakeTool(
491            "kiln_tool::add_numbers",
492            "fake_add",
493            params=EMPTY_SCHEMA,
494            result=ToolCallResult(
495                output="tool failed", is_error=True, error_message="tool failed"
496            ),
497        )
498        code = textwrap.dedent("""\
499            from kiln.tools import ToolCallError
500            from kiln import tools
501            def run(x):
502                try:
503                    tools.fake_add()
504                except ToolCallError as e:
505                    return f"error: {e.message}"
506                return "no error"
507        """)
508        tool = _make_python_code_tool(
509            tmp_path,
510            code,
511            tool_allowlist=["kiln_tool::add_numbers"],
512        )
513        with patch(
514            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
515            return_value=fake,
516        ):
517            result = await tool.run(None, x="test")
518        assert not result.is_error
519        assert "error: tool failed" in result.output
520
521    @pytest.mark.asyncio
522    async def test_nested_tool_not_allowed(self, tmp_path):
523        fake = FakeTool(
524            "kiln_tool::add_numbers",
525            "fake_add",
526            params=EMPTY_SCHEMA,
527        )
528        code = textwrap.dedent("""\
529            from kiln.tools import ToolNotAllowed
530            from kiln import tools
531            def run(x):
532                try:
533                    tools.nonexistent_tool()
534                except ToolNotAllowed as e:
535                    return f"not allowed: {e.tool}"
536                return "no error"
537        """)
538        tool = _make_python_code_tool(
539            tmp_path,
540            code,
541            tool_allowlist=["kiln_tool::add_numbers"],
542        )
543        with patch(
544            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
545            return_value=fake,
546        ):
547            result = await tool.run(None, x="test")
548        assert not result.is_error
549        assert "not allowed: nonexistent_tool" in result.output
550
551    @pytest.mark.asyncio
552    async def test_nested_tool_ambiguous(self, tmp_path):
553        fake1 = FakeTool("mcp::remote::server1::search", "search")
554        fake2 = FakeTool("mcp::remote::server2::search", "search")
555        fakes = {
556            "mcp::remote::server1::search": fake1,
557            "mcp::remote::server2::search": fake2,
558        }
559        code = textwrap.dedent("""\
560            from kiln.tools import ToolCallError
561            from kiln import tools
562            def run(x):
563                try:
564                    tools.search()
565                except ToolCallError as e:
566                    return f"ambiguous: {e.message}"
567                return "no error"
568        """)
569        tool = _make_python_code_tool(
570            tmp_path,
571            code,
572            tool_allowlist=[
573                "mcp::remote::server1::search",
574                "mcp::remote::server2::search",
575            ],
576        )
577        with patch(
578            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
579            side_effect=lambda tid, **kw: fakes[tid],
580        ):
581            result = await tool.run(None, x="test")
582        assert not result.is_error
583        assert "ambiguous" in result.output.lower()
584
585    @pytest.mark.asyncio
586    async def test_nested_tool_invalid_kwargs(self, tmp_path):
587        fake = FakeTool(
588            "kiln_tool::add_numbers",
589            "fake_add",
590            params={
591                "type": "object",
592                "properties": {"a": {"type": "integer"}},
593                "required": ["a"],
594            },
595            result=ToolCallResult(output="42"),
596        )
597        code = textwrap.dedent("""\
598            from kiln.tools import ToolCallError
599            from kiln import tools
600            def run(x):
601                try:
602                    tools.fake_add(a="not_an_int")
603                except ToolCallError as e:
604                    return f"invalid: {e.tool}"
605                return "no error"
606        """)
607        tool = _make_python_code_tool(
608            tmp_path,
609            code,
610            tool_allowlist=["kiln_tool::add_numbers"],
611        )
612        with patch(
613            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
614            return_value=fake,
615        ):
616            result = await tool.run(None, x="test")
617        assert not result.is_error
618        assert "invalid: fake_add" in result.output
@pytest.mark.asyncio
async def test_nested_tool_success(self, tmp_path):
461    @pytest.mark.asyncio
462    async def test_nested_tool_success(self, tmp_path):
463        fake = FakeTool(
464            "kiln_tool::add_numbers",
465            "fake_add",
466            params=EMPTY_SCHEMA,
467            result=ToolCallResult(output="42"),
468        )
469        code = textwrap.dedent("""\
470            from kiln import tools
471            def run(x):
472                result = tools.fake_add()
473                return "got: " + result
474        """)
475        tool = _make_python_code_tool(
476            tmp_path,
477            code,
478            tool_allowlist=["kiln_tool::add_numbers"],
479        )
480        with patch(
481            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
482            return_value=fake,
483        ):
484            result = await tool.run(None, x="test")
485        assert not result.is_error
486        assert result.output == "got: 42"
@pytest.mark.asyncio
async def test_nested_tool_is_error(self, tmp_path):
488    @pytest.mark.asyncio
489    async def test_nested_tool_is_error(self, tmp_path):
490        fake = FakeTool(
491            "kiln_tool::add_numbers",
492            "fake_add",
493            params=EMPTY_SCHEMA,
494            result=ToolCallResult(
495                output="tool failed", is_error=True, error_message="tool failed"
496            ),
497        )
498        code = textwrap.dedent("""\
499            from kiln.tools import ToolCallError
500            from kiln import tools
501            def run(x):
502                try:
503                    tools.fake_add()
504                except ToolCallError as e:
505                    return f"error: {e.message}"
506                return "no error"
507        """)
508        tool = _make_python_code_tool(
509            tmp_path,
510            code,
511            tool_allowlist=["kiln_tool::add_numbers"],
512        )
513        with patch(
514            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
515            return_value=fake,
516        ):
517            result = await tool.run(None, x="test")
518        assert not result.is_error
519        assert "error: tool failed" in result.output
@pytest.mark.asyncio
async def test_nested_tool_not_allowed(self, tmp_path):
521    @pytest.mark.asyncio
522    async def test_nested_tool_not_allowed(self, tmp_path):
523        fake = FakeTool(
524            "kiln_tool::add_numbers",
525            "fake_add",
526            params=EMPTY_SCHEMA,
527        )
528        code = textwrap.dedent("""\
529            from kiln.tools import ToolNotAllowed
530            from kiln import tools
531            def run(x):
532                try:
533                    tools.nonexistent_tool()
534                except ToolNotAllowed as e:
535                    return f"not allowed: {e.tool}"
536                return "no error"
537        """)
538        tool = _make_python_code_tool(
539            tmp_path,
540            code,
541            tool_allowlist=["kiln_tool::add_numbers"],
542        )
543        with patch(
544            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
545            return_value=fake,
546        ):
547            result = await tool.run(None, x="test")
548        assert not result.is_error
549        assert "not allowed: nonexistent_tool" in result.output
@pytest.mark.asyncio
async def test_nested_tool_ambiguous(self, tmp_path):
551    @pytest.mark.asyncio
552    async def test_nested_tool_ambiguous(self, tmp_path):
553        fake1 = FakeTool("mcp::remote::server1::search", "search")
554        fake2 = FakeTool("mcp::remote::server2::search", "search")
555        fakes = {
556            "mcp::remote::server1::search": fake1,
557            "mcp::remote::server2::search": fake2,
558        }
559        code = textwrap.dedent("""\
560            from kiln.tools import ToolCallError
561            from kiln import tools
562            def run(x):
563                try:
564                    tools.search()
565                except ToolCallError as e:
566                    return f"ambiguous: {e.message}"
567                return "no error"
568        """)
569        tool = _make_python_code_tool(
570            tmp_path,
571            code,
572            tool_allowlist=[
573                "mcp::remote::server1::search",
574                "mcp::remote::server2::search",
575            ],
576        )
577        with patch(
578            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
579            side_effect=lambda tid, **kw: fakes[tid],
580        ):
581            result = await tool.run(None, x="test")
582        assert not result.is_error
583        assert "ambiguous" in result.output.lower()
@pytest.mark.asyncio
async def test_nested_tool_invalid_kwargs(self, tmp_path):
585    @pytest.mark.asyncio
586    async def test_nested_tool_invalid_kwargs(self, tmp_path):
587        fake = FakeTool(
588            "kiln_tool::add_numbers",
589            "fake_add",
590            params={
591                "type": "object",
592                "properties": {"a": {"type": "integer"}},
593                "required": ["a"],
594            },
595            result=ToolCallResult(output="42"),
596        )
597        code = textwrap.dedent("""\
598            from kiln.tools import ToolCallError
599            from kiln import tools
600            def run(x):
601                try:
602                    tools.fake_add(a="not_an_int")
603                except ToolCallError as e:
604                    return f"invalid: {e.tool}"
605                return "no error"
606        """)
607        tool = _make_python_code_tool(
608            tmp_path,
609            code,
610            tool_allowlist=["kiln_tool::add_numbers"],
611        )
612        with patch(
613            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
614            return_value=fake,
615        ):
616            result = await tool.run(None, x="test")
617        assert not result.is_error
618        assert "invalid: fake_add" in result.output
class TestListTools:
621class TestListTools:
622    @pytest.mark.asyncio
623    async def test_list_tools_returns_content(self, tmp_path):
624        fake = FakeTool(
625            "kiln_tool::add_numbers",
626            "fake_add",
627            fn_desc="Add two numbers",
628            params={
629                "type": "object",
630                "properties": {"a": {"type": "integer"}},
631            },
632        )
633        code = textwrap.dedent("""\
634            import json
635            from kiln import tools
636            def run(x):
637                tool_list = tools.list_tools()
638                return json.dumps(tool_list)
639        """)
640        tool = _make_python_code_tool(
641            tmp_path,
642            code,
643            tool_allowlist=["kiln_tool::add_numbers"],
644        )
645        with patch(
646            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
647            return_value=fake,
648        ):
649            result = await tool.run(None, x="test")
650        assert not result.is_error
651        tool_list = json.loads(result.output)
652        assert len(tool_list) == 1
653        assert tool_list[0]["name"] == "fake_add"
654        assert tool_list[0]["description"] == "Add two numbers"
@pytest.mark.asyncio
async def test_list_tools_returns_content(self, tmp_path):
622    @pytest.mark.asyncio
623    async def test_list_tools_returns_content(self, tmp_path):
624        fake = FakeTool(
625            "kiln_tool::add_numbers",
626            "fake_add",
627            fn_desc="Add two numbers",
628            params={
629                "type": "object",
630                "properties": {"a": {"type": "integer"}},
631            },
632        )
633        code = textwrap.dedent("""\
634            import json
635            from kiln import tools
636            def run(x):
637                tool_list = tools.list_tools()
638                return json.dumps(tool_list)
639        """)
640        tool = _make_python_code_tool(
641            tmp_path,
642            code,
643            tool_allowlist=["kiln_tool::add_numbers"],
644        )
645        with patch(
646            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
647            return_value=fake,
648        ):
649            result = await tool.run(None, x="test")
650        assert not result.is_error
651        tool_list = json.loads(result.output)
652        assert len(tool_list) == 1
653        assert tool_list[0]["name"] == "fake_add"
654        assert tool_list[0]["description"] == "Add two numbers"
class TestTimeout:
657class TestTimeout:
658    @pytest.mark.asyncio
659    async def test_timeout_kills_child(self, tmp_path):
660        tool = _make_python_code_tool(
661            tmp_path,
662            "import time\ndef run(x):\n    time.sleep(30)\n    return 'done'\n",
663            timeout_seconds=1,
664        )
665        result = await tool.run(None, x="test")
666        assert result.is_error
667        assert "timed out" in result.output
668
669    @pytest.mark.asyncio
670    async def test_timeout_during_nested_call(self, tmp_path):
671        slow_fake = FakeTool(
672            "kiln_tool::add_numbers",
673            "fake_add",
674            params=EMPTY_SCHEMA,
675            result=ToolCallResult(output="42"),
676            delay=30,
677        )
678        code = textwrap.dedent("""\
679            from kiln import tools
680            def run(x):
681                return tools.fake_add()
682        """)
683        tool = _make_python_code_tool(
684            tmp_path,
685            code,
686            tool_allowlist=["kiln_tool::add_numbers"],
687            timeout_seconds=1,
688        )
689        with patch(
690            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
691            return_value=slow_fake,
692        ):
693            result = await tool.run(None, x="test")
694        assert result.is_error
695        assert "timed out" in result.output
@pytest.mark.asyncio
async def test_timeout_kills_child(self, tmp_path):
658    @pytest.mark.asyncio
659    async def test_timeout_kills_child(self, tmp_path):
660        tool = _make_python_code_tool(
661            tmp_path,
662            "import time\ndef run(x):\n    time.sleep(30)\n    return 'done'\n",
663            timeout_seconds=1,
664        )
665        result = await tool.run(None, x="test")
666        assert result.is_error
667        assert "timed out" in result.output
@pytest.mark.asyncio
async def test_timeout_during_nested_call(self, tmp_path):
669    @pytest.mark.asyncio
670    async def test_timeout_during_nested_call(self, tmp_path):
671        slow_fake = FakeTool(
672            "kiln_tool::add_numbers",
673            "fake_add",
674            params=EMPTY_SCHEMA,
675            result=ToolCallResult(output="42"),
676            delay=30,
677        )
678        code = textwrap.dedent("""\
679            from kiln import tools
680            def run(x):
681                return tools.fake_add()
682        """)
683        tool = _make_python_code_tool(
684            tmp_path,
685            code,
686            tool_allowlist=["kiln_tool::add_numbers"],
687            timeout_seconds=1,
688        )
689        with patch(
690            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
691            return_value=slow_fake,
692        ):
693            result = await tool.run(None, x="test")
694        assert result.is_error
695        assert "timed out" in result.output
class TestCrash:
698class TestCrash:
699    @pytest.mark.asyncio
700    async def test_crash_via_os_exit(self, tmp_path):
701        tool = _make_python_code_tool(
702            tmp_path,
703            "import os\ndef run(x):\n    os._exit(3)\n",
704        )
705        result = await tool.run(None, x="test")
706        assert result.is_error
707        assert "crashed" in result.output
708        assert "exit code" in result.output
@pytest.mark.asyncio
async def test_crash_via_os_exit(self, tmp_path):
699    @pytest.mark.asyncio
700    async def test_crash_via_os_exit(self, tmp_path):
701        tool = _make_python_code_tool(
702            tmp_path,
703            "import os\ndef run(x):\n    os._exit(3)\n",
704        )
705        result = await tool.run(None, x="test")
706        assert result.is_error
707        assert "crashed" in result.output
708        assert "exit code" in result.output
class TestDepthCap:
711class TestDepthCap:
712    @pytest.mark.asyncio
713    async def test_depth_cap_at_10(self, tmp_path):
714        """Depth >= 10 returns an error without spawning."""
715        tool = _make_python_code_tool(tmp_path, 'def run(x):\n    return "ok"\n')
716        token = _depth.set(10)
717        try:
718            result = await tool.run(None, x="test")
719        finally:
720            _depth.reset(token)
721        assert result.is_error
722        assert "maximum nested code execution depth exceeded" in result.output
@pytest.mark.asyncio
async def test_depth_cap_at_10(self, tmp_path):
712    @pytest.mark.asyncio
713    async def test_depth_cap_at_10(self, tmp_path):
714        """Depth >= 10 returns an error without spawning."""
715        tool = _make_python_code_tool(tmp_path, 'def run(x):\n    return "ok"\n')
716        token = _depth.set(10)
717        try:
718            result = await tool.run(None, x="test")
719        finally:
720            _depth.reset(token)
721        assert result.is_error
722        assert "maximum nested code execution depth exceeded" in result.output

Depth >= 10 returns an error without spawning.

class TestSemaphore:
725class TestSemaphore:
726    @pytest.mark.asyncio
727    async def test_semaphore_top_level_only_no_deadlock(self, tmp_path):
728        """Regression: nested code-tool calls bypass the semaphore.
729
730        If nested calls counted against the semaphore, 8 parents each
731        spawning a nested code-tool child would deadlock (parents hold
732        all 8 slots, children wait forever).
733
734        This test sets MAX_CONCURRENCY parents running concurrently,
735        each at depth 1 (simulating nested calls). All should complete
736        without deadlock because nested calls bypass the semaphore.
737        """
738        code = 'def run(x):\n    return "nested_ok"\n'
739        results = []
740
741        async def run_nested(i: int):
742            tool = _make_python_code_tool(tmp_path, code)
743            token = _depth.set(1)
744            try:
745                r = await tool.run(None, x=str(i))
746                results.append(r)
747            finally:
748                _depth.reset(token)
749
750        await asyncio.gather(
751            *(run_nested(i) for i in range(CODE_SANDBOX_MAX_CONCURRENCY))
752        )
753        assert len(results) == CODE_SANDBOX_MAX_CONCURRENCY
754        assert all(not r.is_error for r in results)
@pytest.mark.asyncio
async def test_semaphore_top_level_only_no_deadlock(self, tmp_path):
726    @pytest.mark.asyncio
727    async def test_semaphore_top_level_only_no_deadlock(self, tmp_path):
728        """Regression: nested code-tool calls bypass the semaphore.
729
730        If nested calls counted against the semaphore, 8 parents each
731        spawning a nested code-tool child would deadlock (parents hold
732        all 8 slots, children wait forever).
733
734        This test sets MAX_CONCURRENCY parents running concurrently,
735        each at depth 1 (simulating nested calls). All should complete
736        without deadlock because nested calls bypass the semaphore.
737        """
738        code = 'def run(x):\n    return "nested_ok"\n'
739        results = []
740
741        async def run_nested(i: int):
742            tool = _make_python_code_tool(tmp_path, code)
743            token = _depth.set(1)
744            try:
745                r = await tool.run(None, x=str(i))
746                results.append(r)
747            finally:
748                _depth.reset(token)
749
750        await asyncio.gather(
751            *(run_nested(i) for i in range(CODE_SANDBOX_MAX_CONCURRENCY))
752        )
753        assert len(results) == CODE_SANDBOX_MAX_CONCURRENCY
754        assert all(not r.is_error for r in results)

Regression: nested code-tool calls bypass the semaphore.

If nested calls counted against the semaphore, 8 parents each spawning a nested code-tool child would deadlock (parents hold all 8 slots, children wait forever).

This test sets MAX_CONCURRENCY parents running concurrently, each at depth 1 (simulating nested calls). All should complete without deadlock because nested calls bypass the semaphore.

class TestToolCallRecorder:
757class TestToolCallRecorder:
758    @pytest.mark.asyncio
759    async def test_recorder_gets_entries(self, tmp_path):
760        fake = FakeTool(
761            "kiln_tool::add_numbers",
762            "fake_add",
763            params=EMPTY_SCHEMA,
764            result=ToolCallResult(output="42"),
765        )
766        log: list[ToolCallLogEntry] = []
767        code = textwrap.dedent("""\
768            from kiln import tools
769            def run(x):
770                r = tools.fake_add()
771                return r
772        """)
773        tool = _make_python_code_tool(
774            tmp_path,
775            code,
776            tool_allowlist=["kiln_tool::add_numbers"],
777            tool_call_recorder=log.append,
778        )
779        with patch(
780            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
781            return_value=fake,
782        ):
783            result = await tool.run(None, x="test")
784        assert not result.is_error
785        assert len(log) == 1
786        assert log[0].tool_name == "fake_add"
787        assert not log[0].is_error
788        assert log[0].output_preview == "42"
789        assert log[0].duration_ms >= 0
@pytest.mark.asyncio
async def test_recorder_gets_entries(self, tmp_path):
758    @pytest.mark.asyncio
759    async def test_recorder_gets_entries(self, tmp_path):
760        fake = FakeTool(
761            "kiln_tool::add_numbers",
762            "fake_add",
763            params=EMPTY_SCHEMA,
764            result=ToolCallResult(output="42"),
765        )
766        log: list[ToolCallLogEntry] = []
767        code = textwrap.dedent("""\
768            from kiln import tools
769            def run(x):
770                r = tools.fake_add()
771                return r
772        """)
773        tool = _make_python_code_tool(
774            tmp_path,
775            code,
776            tool_allowlist=["kiln_tool::add_numbers"],
777            tool_call_recorder=log.append,
778        )
779        with patch(
780            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
781            return_value=fake,
782        ):
783            result = await tool.run(None, x="test")
784        assert not result.is_error
785        assert len(log) == 1
786        assert log[0].tool_name == "fake_add"
787        assert not log[0].is_error
788        assert log[0].output_preview == "42"
789        assert log[0].duration_ms >= 0
class TestAsyncToolsConcurrency:
792class TestAsyncToolsConcurrency:
793    @pytest.mark.asyncio
794    async def test_async_tools_gather_truly_concurrent(self, tmp_path):
795        """async_tools + gather provides real parallelism via to_thread.
796
797        Two fake tools each take ~0.3s. If sequential, wall clock >= 0.6s.
798        With true concurrency via gather + to_thread, wall clock < 0.6s.
799        """
800        slow_fake = FakeTool(
801            "kiln_tool::add_numbers",
802            "fake_add",
803            params=EMPTY_SCHEMA,
804            result=ToolCallResult(output="done"),
805            delay=0.3,
806        )
807        code = textwrap.dedent("""\
808            import asyncio, time
809            from kiln import async_tools
810            async def run(x):
811                start = time.monotonic()
812                a, b = await asyncio.gather(
813                    async_tools.fake_add(),
814                    async_tools.fake_add(),
815                )
816                elapsed = time.monotonic() - start
817                return f"{elapsed:.2f}"
818        """)
819        tool = _make_python_code_tool(
820            tmp_path,
821            code,
822            tool_allowlist=["kiln_tool::add_numbers"],
823        )
824        with patch(
825            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
826            return_value=slow_fake,
827        ):
828            result = await tool.run(None, x="test")
829        assert not result.is_error
830        elapsed = float(result.output)
831        assert elapsed < 0.6, f"Expected < 0.6s (concurrent), got {elapsed:.2f}s"
@pytest.mark.asyncio
async def test_async_tools_gather_truly_concurrent(self, tmp_path):
793    @pytest.mark.asyncio
794    async def test_async_tools_gather_truly_concurrent(self, tmp_path):
795        """async_tools + gather provides real parallelism via to_thread.
796
797        Two fake tools each take ~0.3s. If sequential, wall clock >= 0.6s.
798        With true concurrency via gather + to_thread, wall clock < 0.6s.
799        """
800        slow_fake = FakeTool(
801            "kiln_tool::add_numbers",
802            "fake_add",
803            params=EMPTY_SCHEMA,
804            result=ToolCallResult(output="done"),
805            delay=0.3,
806        )
807        code = textwrap.dedent("""\
808            import asyncio, time
809            from kiln import async_tools
810            async def run(x):
811                start = time.monotonic()
812                a, b = await asyncio.gather(
813                    async_tools.fake_add(),
814                    async_tools.fake_add(),
815                )
816                elapsed = time.monotonic() - start
817                return f"{elapsed:.2f}"
818        """)
819        tool = _make_python_code_tool(
820            tmp_path,
821            code,
822            tool_allowlist=["kiln_tool::add_numbers"],
823        )
824        with patch(
825            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
826            return_value=slow_fake,
827        ):
828            result = await tool.run(None, x="test")
829        assert not result.is_error
830        elapsed = float(result.output)
831        assert elapsed < 0.6, f"Expected < 0.6s (concurrent), got {elapsed:.2f}s"

async_tools + gather provides real parallelism via to_thread.

Two fake tools each take ~0.3s. If sequential, wall clock >= 0.6s. With true concurrency via gather + to_thread, wall clock < 0.6s.

class TestCallIdRouting:
865class TestCallIdRouting:
866    @pytest.mark.asyncio
867    async def test_concurrent_calls_routed_to_correct_caller(self, tmp_path):
868        """4 threads each pass a unique idx kwarg; each gets its own value back.
869
870        The echo tool returns the kwargs it received. Each thread asserts it
871        got back the idx it sent, proving call_id routing maps the right
872        response to the right waiting caller under concurrency.
873        """
874        idx_schema = {
875            "type": "object",
876            "properties": {"idx": {"type": "string"}},
877            "required": ["idx"],
878        }
879        code = textwrap.dedent("""\
880            import json, threading
881            from kiln import tools
882            def run(x):
883                results = [None] * 4
884                errors = []
885                def call_tool(i):
886                    try:
887                        raw = tools.fake_echo(idx=str(i))
888                        results[i] = json.loads(raw)["idx"]
889                    except Exception as e:
890                        errors.append(f"thread {i}: {e}")
891                threads = [threading.Thread(target=call_tool, args=(i,)) for i in range(4)]
892                for t in threads:
893                    t.start()
894                for t in threads:
895                    t.join()
896                if errors:
897                    return "errors: " + str(errors)
898                return ",".join(results)
899        """)
900        fake = _EchoFakeTool(
901            "kiln_tool::add_numbers",
902            "fake_echo",
903            params=idx_schema,
904        )
905        tool = _make_python_code_tool(
906            tmp_path,
907            code,
908            tool_allowlist=["kiln_tool::add_numbers"],
909        )
910        with patch(
911            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
912            return_value=fake,
913        ):
914            result = await tool.run(None, x="test")
915        assert not result.is_error
916        parts = result.output.split(",")
917        assert len(parts) == 4
918        assert parts == ["0", "1", "2", "3"]
@pytest.mark.asyncio
async def test_concurrent_calls_routed_to_correct_caller(self, tmp_path):
866    @pytest.mark.asyncio
867    async def test_concurrent_calls_routed_to_correct_caller(self, tmp_path):
868        """4 threads each pass a unique idx kwarg; each gets its own value back.
869
870        The echo tool returns the kwargs it received. Each thread asserts it
871        got back the idx it sent, proving call_id routing maps the right
872        response to the right waiting caller under concurrency.
873        """
874        idx_schema = {
875            "type": "object",
876            "properties": {"idx": {"type": "string"}},
877            "required": ["idx"],
878        }
879        code = textwrap.dedent("""\
880            import json, threading
881            from kiln import tools
882            def run(x):
883                results = [None] * 4
884                errors = []
885                def call_tool(i):
886                    try:
887                        raw = tools.fake_echo(idx=str(i))
888                        results[i] = json.loads(raw)["idx"]
889                    except Exception as e:
890                        errors.append(f"thread {i}: {e}")
891                threads = [threading.Thread(target=call_tool, args=(i,)) for i in range(4)]
892                for t in threads:
893                    t.start()
894                for t in threads:
895                    t.join()
896                if errors:
897                    return "errors: " + str(errors)
898                return ",".join(results)
899        """)
900        fake = _EchoFakeTool(
901            "kiln_tool::add_numbers",
902            "fake_echo",
903            params=idx_schema,
904        )
905        tool = _make_python_code_tool(
906            tmp_path,
907            code,
908            tool_allowlist=["kiln_tool::add_numbers"],
909        )
910        with patch(
911            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
912            return_value=fake,
913        ):
914            result = await tool.run(None, x="test")
915        assert not result.is_error
916        parts = result.output.split(",")
917        assert len(parts) == 4
918        assert parts == ["0", "1", "2", "3"]

4 threads each pass a unique idx kwarg; each gets its own value back.

The echo tool returns the kwargs it received. Each thread asserts it got back the idx it sent, proving call_id routing maps the right response to the right waiting caller under concurrency.

class TestUnicodePassthrough:
921class TestUnicodePassthrough:
922    @pytest.mark.asyncio
923    async def test_unicode_not_escaped(self, tmp_path):
924        """ensure_ascii=False: non-ASCII chars pass through un-escaped."""
925        tool = _make_python_code_tool(
926            tmp_path,
927            'def run(x):\n    return {"name": "\\u65e5\\u672c\\u8a9e"}\n',
928        )
929        result = await tool.run(None, x="test")
930        assert not result.is_error
931        parsed = json.loads(result.output)
932        assert parsed["name"] == "日本語"
933        assert "\\u" not in result.output
@pytest.mark.asyncio
async def test_unicode_not_escaped(self, tmp_path):
922    @pytest.mark.asyncio
923    async def test_unicode_not_escaped(self, tmp_path):
924        """ensure_ascii=False: non-ASCII chars pass through un-escaped."""
925        tool = _make_python_code_tool(
926            tmp_path,
927            'def run(x):\n    return {"name": "\\u65e5\\u672c\\u8a9e"}\n',
928        )
929        result = await tool.run(None, x="test")
930        assert not result.is_error
931        parsed = json.loads(result.output)
932        assert parsed["name"] == "日本語"
933        assert "\\u" not in result.output

ensure_ascii=False: non-ASCII chars pass through un-escaped.

class TestSpawnLockIdentity:
936class TestSpawnLockIdentity:
937    def test_spawn_lock_shared(self):
938        """Code tools and code evals spawn through the same bridge / _spawn_lock."""
939        from kiln_ai.tools import sandbox_bridge
940
941        assert (
942            sandbox_bridge.start_process_with_light_main.__module__
943            == "kiln_ai.sandbox.spawn"
944        )
945        from kiln_ai.sandbox.spawn import _spawn_lock as shared_lock
946
947        assert shared_lock is _spawn_lock
def test_spawn_lock_shared(self):
937    def test_spawn_lock_shared(self):
938        """Code tools and code evals spawn through the same bridge / _spawn_lock."""
939        from kiln_ai.tools import sandbox_bridge
940
941        assert (
942            sandbox_bridge.start_process_with_light_main.__module__
943            == "kiln_ai.sandbox.spawn"
944        )
945        from kiln_ai.sandbox.spawn import _spawn_lock as shared_lock
946
947        assert shared_lock is _spawn_lock

Code tools and code evals spawn through the same bridge / _spawn_lock.

class TestRealBuiltInTools:
 955class TestRealBuiltInTools:
 956    """Tests that exercise real built-in tool dispatch without mocking
 957    ``tool_from_id_and_project``, ensuring the name-derivation path is
 958    exercised end-to-end.
 959    """
 960
 961    @pytest.mark.asyncio
 962    async def test_keyword_call_by_canonical_name_succeeds(self, tmp_path):
 963        """tools.add(a=1, b=2) returns '3' — the canonical name from list_tools."""
 964        code = textwrap.dedent("""\
 965            from kiln import tools
 966            def run(x):
 967                return tools.add(a=1, b=2)
 968        """)
 969        tool = _make_python_code_tool(
 970            tmp_path,
 971            code,
 972            tool_allowlist=["kiln_tool::add_numbers"],
 973        )
 974        result = await tool.run(None, x="test")
 975        assert not result.is_error, f"Expected success, got: {result.output}"
 976        assert result.output == "3"
 977
 978    @pytest.mark.asyncio
 979    async def test_list_tools_driven_call_succeeds(self, tmp_path):
 980        """Call using the name returned by list_tools() succeeds."""
 981        code = textwrap.dedent("""\
 982            from kiln import tools
 983            def run(x):
 984                tl = tools.list_tools()
 985                fn_name = tl[0]["name"]
 986                result = getattr(tools, fn_name)(a=5, b=3)
 987                return fn_name + ":" + result
 988        """)
 989        tool = _make_python_code_tool(
 990            tmp_path,
 991            code,
 992            tool_allowlist=["kiln_tool::add_numbers"],
 993        )
 994        result = await tool.run(None, x="test")
 995        assert not result.is_error, f"Expected success, got: {result.output}"
 996        assert result.output == "add:8"
 997
 998    @pytest.mark.asyncio
 999    async def test_friendly_name_not_allowed(self, tmp_path):
1000        """tools.Addition(a=1,b=2) raises ToolNotAllowed listing canonical names."""
1001        code = textwrap.dedent("""\
1002            from kiln.tools import ToolNotAllowed
1003            from kiln import tools
1004            def run(x):
1005                try:
1006                    tools.Addition(a=1, b=2)
1007                except ToolNotAllowed as e:
1008                    return e.message
1009                return "no error"
1010        """)
1011        tool = _make_python_code_tool(
1012            tmp_path,
1013            code,
1014            tool_allowlist=["kiln_tool::add_numbers"],
1015        )
1016        result = await tool.run(None, x="test")
1017        assert not result.is_error
1018        assert "not available" in result.output
1019        assert "'add'" in result.output
1020
1021    @pytest.mark.asyncio
1022    async def test_nonsense_name_not_allowed(self, tmp_path):
1023        """tools.bad_tool() raises ToolNotAllowed listing available names."""
1024        code = textwrap.dedent("""\
1025            from kiln.tools import ToolNotAllowed
1026            from kiln import tools
1027            def run(x):
1028                try:
1029                    tools.bad_tool(a=1)
1030                except ToolNotAllowed as e:
1031                    return e.message
1032                return "no error"
1033        """)
1034        tool = _make_python_code_tool(
1035            tmp_path,
1036            code,
1037            tool_allowlist=["kiln_tool::add_numbers"],
1038        )
1039        result = await tool.run(None, x="test")
1040        assert not result.is_error
1041        assert "not available" in result.output
1042        assert "'add'" in result.output
1043
1044    @pytest.mark.asyncio
1045    async def test_positional_args_error_message(self, tmp_path):
1046        """tools.add(1, 2) raises ToolCallError mentioning keyword args and params."""
1047        code = textwrap.dedent("""\
1048            from kiln.tools import ToolCallError
1049            from kiln import tools
1050            def run(x):
1051                try:
1052                    tools.add(1, 2)
1053                except ToolCallError as e:
1054                    return e.message
1055                return "no error"
1056        """)
1057        tool = _make_python_code_tool(
1058            tmp_path,
1059            code,
1060            tool_allowlist=["kiln_tool::add_numbers"],
1061        )
1062        result = await tool.run(None, x="test")
1063        assert not result.is_error
1064        assert "keyword arguments" in result.output
1065        assert "tools.add(" in result.output
1066        assert "a: number (required)" in result.output
1067        assert "b: number (required)" in result.output
1068
1069    @pytest.mark.asyncio
1070    async def test_wrong_kwargs_error_shows_schema(self, tmp_path):
1071        """tools.add(x=1) raises ToolCallError showing expected parameters."""
1072        code = textwrap.dedent("""\
1073            from kiln.tools import ToolCallError
1074            from kiln import tools
1075            def run(x):
1076                try:
1077                    tools.add(x=1)
1078                except ToolCallError as e:
1079                    return e.message
1080                return "no error"
1081        """)
1082        tool = _make_python_code_tool(
1083            tmp_path,
1084            code,
1085            tool_allowlist=["kiln_tool::add_numbers"],
1086        )
1087        result = await tool.run(None, x="test")
1088        assert not result.is_error
1089        assert "Expected parameters:" in result.output
1090        assert "a: number (required)" in result.output
1091
1092    @pytest.mark.asyncio
1093    async def test_name_consistency_across_all_builtins(self, tmp_path):
1094        """For every KilnBuiltInToolId the dispatch-map name matches tool.name()
1095        AND matches what list_tools reports."""
1096        from kiln_ai.datamodel.tool_id import KilnBuiltInToolId
1097        from kiln_ai.tools.tool_registry import tool_from_id_and_project
1098
1099        project = _make_project(tmp_path)
1100
1101        math_ids = [
1102            KilnBuiltInToolId.ADD_NUMBERS,
1103            KilnBuiltInToolId.SUBTRACT_NUMBERS,
1104            KilnBuiltInToolId.MULTIPLY_NUMBERS,
1105            KilnBuiltInToolId.DIVIDE_NUMBERS,
1106        ]
1107
1108        for builtin_id in math_ids:
1109            tool_id = builtin_id.value
1110            real_tool = tool_from_id_and_project(tool_id, project=project)
1111            real_name = await real_tool.name()
1112
1113            ct = _make_code_tool(
1114                'def run(x): return "ok"',
1115                tool_allowlist=[tool_id],
1116            )
1117            ct.parent = project
1118            server = NestedToolServer(
1119                allowlist=ct.tool_allowlist, project=project, task=None, context=None
1120            )
1121            dispatch_names = list((await server.name_map()).keys())
1122
1123            assert dispatch_names == [real_name], (
1124                f"For {builtin_id}: dispatch name {dispatch_names} != "
1125                f"tool.name() '{real_name}'"
1126            )
1127
1128    @pytest.mark.asyncio
1129    async def test_async_proxy_keyword_call(self, tmp_path):
1130        """async_tools.subtract(a=5, b=3) returns '2'."""
1131        code = textwrap.dedent("""\
1132            from kiln import async_tools
1133            async def run(x):
1134                return await async_tools.subtract(a=5, b=3)
1135        """)
1136        tool = _make_python_code_tool(
1137            tmp_path,
1138            code,
1139            tool_allowlist=["kiln_tool::subtract_numbers"],
1140        )
1141        result = await tool.run(None, x="test")
1142        assert not result.is_error, f"Expected success, got: {result.output}"
1143        assert result.output == "2"
1144
1145    @pytest.mark.asyncio
1146    async def test_async_proxy_positional_error(self, tmp_path):
1147        """async_tools.add(1, 2) raises ToolCallError with a helpful message."""
1148        code = textwrap.dedent("""\
1149            from kiln.tools import ToolCallError
1150            from kiln import async_tools
1151            async def run(x):
1152                try:
1153                    await async_tools.add(1, 2)
1154                except ToolCallError as e:
1155                    return e.message
1156                return "no error"
1157        """)
1158        tool = _make_python_code_tool(
1159            tmp_path,
1160            code,
1161            tool_allowlist=["kiln_tool::add_numbers"],
1162        )
1163        result = await tool.run(None, x="test")
1164        assert not result.is_error
1165        assert "keyword arguments" in result.output
1166
1167    @pytest.mark.asyncio
1168    async def test_positional_on_nonsense_name_still_not_allowed(self, tmp_path):
1169        """tools.bad_tool(1) raises ToolNotAllowed (not TypeError), regardless of args."""
1170        code = textwrap.dedent("""\
1171            from kiln.tools import ToolNotAllowed
1172            from kiln import tools
1173            def run(x):
1174                try:
1175                    tools.bad_tool(1, 2)
1176                except ToolNotAllowed as e:
1177                    return e.message
1178                return "no error"
1179        """)
1180        tool = _make_python_code_tool(
1181            tmp_path,
1182            code,
1183            tool_allowlist=["kiln_tool::add_numbers"],
1184        )
1185        result = await tool.run(None, x="test")
1186        assert not result.is_error
1187        assert "not available" in result.output

Tests that exercise real built-in tool dispatch without mocking tool_from_id_and_project, ensuring the name-derivation path is exercised end-to-end.

@pytest.mark.asyncio
async def test_keyword_call_by_canonical_name_succeeds(self, tmp_path):
961    @pytest.mark.asyncio
962    async def test_keyword_call_by_canonical_name_succeeds(self, tmp_path):
963        """tools.add(a=1, b=2) returns '3' — the canonical name from list_tools."""
964        code = textwrap.dedent("""\
965            from kiln import tools
966            def run(x):
967                return tools.add(a=1, b=2)
968        """)
969        tool = _make_python_code_tool(
970            tmp_path,
971            code,
972            tool_allowlist=["kiln_tool::add_numbers"],
973        )
974        result = await tool.run(None, x="test")
975        assert not result.is_error, f"Expected success, got: {result.output}"
976        assert result.output == "3"

tools.add(a=1, b=2) returns '3' — the canonical name from list_tools.

@pytest.mark.asyncio
async def test_list_tools_driven_call_succeeds(self, tmp_path):
978    @pytest.mark.asyncio
979    async def test_list_tools_driven_call_succeeds(self, tmp_path):
980        """Call using the name returned by list_tools() succeeds."""
981        code = textwrap.dedent("""\
982            from kiln import tools
983            def run(x):
984                tl = tools.list_tools()
985                fn_name = tl[0]["name"]
986                result = getattr(tools, fn_name)(a=5, b=3)
987                return fn_name + ":" + result
988        """)
989        tool = _make_python_code_tool(
990            tmp_path,
991            code,
992            tool_allowlist=["kiln_tool::add_numbers"],
993        )
994        result = await tool.run(None, x="test")
995        assert not result.is_error, f"Expected success, got: {result.output}"
996        assert result.output == "add:8"

Call using the name returned by list_tools() succeeds.

@pytest.mark.asyncio
async def test_friendly_name_not_allowed(self, tmp_path):
 998    @pytest.mark.asyncio
 999    async def test_friendly_name_not_allowed(self, tmp_path):
1000        """tools.Addition(a=1,b=2) raises ToolNotAllowed listing canonical names."""
1001        code = textwrap.dedent("""\
1002            from kiln.tools import ToolNotAllowed
1003            from kiln import tools
1004            def run(x):
1005                try:
1006                    tools.Addition(a=1, b=2)
1007                except ToolNotAllowed as e:
1008                    return e.message
1009                return "no error"
1010        """)
1011        tool = _make_python_code_tool(
1012            tmp_path,
1013            code,
1014            tool_allowlist=["kiln_tool::add_numbers"],
1015        )
1016        result = await tool.run(None, x="test")
1017        assert not result.is_error
1018        assert "not available" in result.output
1019        assert "'add'" in result.output

tools.Addition(a=1,b=2) raises ToolNotAllowed listing canonical names.

@pytest.mark.asyncio
async def test_nonsense_name_not_allowed(self, tmp_path):
1021    @pytest.mark.asyncio
1022    async def test_nonsense_name_not_allowed(self, tmp_path):
1023        """tools.bad_tool() raises ToolNotAllowed listing available names."""
1024        code = textwrap.dedent("""\
1025            from kiln.tools import ToolNotAllowed
1026            from kiln import tools
1027            def run(x):
1028                try:
1029                    tools.bad_tool(a=1)
1030                except ToolNotAllowed as e:
1031                    return e.message
1032                return "no error"
1033        """)
1034        tool = _make_python_code_tool(
1035            tmp_path,
1036            code,
1037            tool_allowlist=["kiln_tool::add_numbers"],
1038        )
1039        result = await tool.run(None, x="test")
1040        assert not result.is_error
1041        assert "not available" in result.output
1042        assert "'add'" in result.output

tools.bad_tool() raises ToolNotAllowed listing available names.

@pytest.mark.asyncio
async def test_positional_args_error_message(self, tmp_path):
1044    @pytest.mark.asyncio
1045    async def test_positional_args_error_message(self, tmp_path):
1046        """tools.add(1, 2) raises ToolCallError mentioning keyword args and params."""
1047        code = textwrap.dedent("""\
1048            from kiln.tools import ToolCallError
1049            from kiln import tools
1050            def run(x):
1051                try:
1052                    tools.add(1, 2)
1053                except ToolCallError as e:
1054                    return e.message
1055                return "no error"
1056        """)
1057        tool = _make_python_code_tool(
1058            tmp_path,
1059            code,
1060            tool_allowlist=["kiln_tool::add_numbers"],
1061        )
1062        result = await tool.run(None, x="test")
1063        assert not result.is_error
1064        assert "keyword arguments" in result.output
1065        assert "tools.add(" in result.output
1066        assert "a: number (required)" in result.output
1067        assert "b: number (required)" in result.output

tools.add(1, 2) raises ToolCallError mentioning keyword args and params.

@pytest.mark.asyncio
async def test_wrong_kwargs_error_shows_schema(self, tmp_path):
1069    @pytest.mark.asyncio
1070    async def test_wrong_kwargs_error_shows_schema(self, tmp_path):
1071        """tools.add(x=1) raises ToolCallError showing expected parameters."""
1072        code = textwrap.dedent("""\
1073            from kiln.tools import ToolCallError
1074            from kiln import tools
1075            def run(x):
1076                try:
1077                    tools.add(x=1)
1078                except ToolCallError as e:
1079                    return e.message
1080                return "no error"
1081        """)
1082        tool = _make_python_code_tool(
1083            tmp_path,
1084            code,
1085            tool_allowlist=["kiln_tool::add_numbers"],
1086        )
1087        result = await tool.run(None, x="test")
1088        assert not result.is_error
1089        assert "Expected parameters:" in result.output
1090        assert "a: number (required)" in result.output

tools.add(x=1) raises ToolCallError showing expected parameters.

@pytest.mark.asyncio
async def test_name_consistency_across_all_builtins(self, tmp_path):
1092    @pytest.mark.asyncio
1093    async def test_name_consistency_across_all_builtins(self, tmp_path):
1094        """For every KilnBuiltInToolId the dispatch-map name matches tool.name()
1095        AND matches what list_tools reports."""
1096        from kiln_ai.datamodel.tool_id import KilnBuiltInToolId
1097        from kiln_ai.tools.tool_registry import tool_from_id_and_project
1098
1099        project = _make_project(tmp_path)
1100
1101        math_ids = [
1102            KilnBuiltInToolId.ADD_NUMBERS,
1103            KilnBuiltInToolId.SUBTRACT_NUMBERS,
1104            KilnBuiltInToolId.MULTIPLY_NUMBERS,
1105            KilnBuiltInToolId.DIVIDE_NUMBERS,
1106        ]
1107
1108        for builtin_id in math_ids:
1109            tool_id = builtin_id.value
1110            real_tool = tool_from_id_and_project(tool_id, project=project)
1111            real_name = await real_tool.name()
1112
1113            ct = _make_code_tool(
1114                'def run(x): return "ok"',
1115                tool_allowlist=[tool_id],
1116            )
1117            ct.parent = project
1118            server = NestedToolServer(
1119                allowlist=ct.tool_allowlist, project=project, task=None, context=None
1120            )
1121            dispatch_names = list((await server.name_map()).keys())
1122
1123            assert dispatch_names == [real_name], (
1124                f"For {builtin_id}: dispatch name {dispatch_names} != "
1125                f"tool.name() '{real_name}'"
1126            )

For every KilnBuiltInToolId the dispatch-map name matches tool.name() AND matches what list_tools reports.

@pytest.mark.asyncio
async def test_async_proxy_keyword_call(self, tmp_path):
1128    @pytest.mark.asyncio
1129    async def test_async_proxy_keyword_call(self, tmp_path):
1130        """async_tools.subtract(a=5, b=3) returns '2'."""
1131        code = textwrap.dedent("""\
1132            from kiln import async_tools
1133            async def run(x):
1134                return await async_tools.subtract(a=5, b=3)
1135        """)
1136        tool = _make_python_code_tool(
1137            tmp_path,
1138            code,
1139            tool_allowlist=["kiln_tool::subtract_numbers"],
1140        )
1141        result = await tool.run(None, x="test")
1142        assert not result.is_error, f"Expected success, got: {result.output}"
1143        assert result.output == "2"

async_tools.subtract(a=5, b=3) returns '2'.

@pytest.mark.asyncio
async def test_async_proxy_positional_error(self, tmp_path):
1145    @pytest.mark.asyncio
1146    async def test_async_proxy_positional_error(self, tmp_path):
1147        """async_tools.add(1, 2) raises ToolCallError with a helpful message."""
1148        code = textwrap.dedent("""\
1149            from kiln.tools import ToolCallError
1150            from kiln import async_tools
1151            async def run(x):
1152                try:
1153                    await async_tools.add(1, 2)
1154                except ToolCallError as e:
1155                    return e.message
1156                return "no error"
1157        """)
1158        tool = _make_python_code_tool(
1159            tmp_path,
1160            code,
1161            tool_allowlist=["kiln_tool::add_numbers"],
1162        )
1163        result = await tool.run(None, x="test")
1164        assert not result.is_error
1165        assert "keyword arguments" in result.output

async_tools.add(1, 2) raises ToolCallError with a helpful message.

@pytest.mark.asyncio
async def test_positional_on_nonsense_name_still_not_allowed(self, tmp_path):
1167    @pytest.mark.asyncio
1168    async def test_positional_on_nonsense_name_still_not_allowed(self, tmp_path):
1169        """tools.bad_tool(1) raises ToolNotAllowed (not TypeError), regardless of args."""
1170        code = textwrap.dedent("""\
1171            from kiln.tools import ToolNotAllowed
1172            from kiln import tools
1173            def run(x):
1174                try:
1175                    tools.bad_tool(1, 2)
1176                except ToolNotAllowed as e:
1177                    return e.message
1178                return "no error"
1179        """)
1180        tool = _make_python_code_tool(
1181            tmp_path,
1182            code,
1183            tool_allowlist=["kiln_tool::add_numbers"],
1184        )
1185        result = await tool.run(None, x="test")
1186        assert not result.is_error
1187        assert "not available" in result.output

tools.bad_tool(1) raises ToolNotAllowed (not TypeError), regardless of args.

EXAMPLE_PARALLEL_WITH_RETRIES = 'import json\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom kiln import tools\n\ndef run(urls: list[str], max_retries: int = 3) -> str:\n """Fetch multiple URLs in parallel with retries."""\n results = {}\n\n def fetch_with_retry(url):\n for attempt in range(max_retries):\n try:\n result = tools.fetch_url(url=url)\n return url, json.loads(result)\n except Exception as e:\n if attempt == max_retries - 1:\n return url, {"error": str(e)}\n time.sleep(0.5 * (attempt + 1))\n\n with ThreadPoolExecutor(max_workers=5) as pool:\n futures = [pool.submit(fetch_with_retry, u) for u in urls]\n for future in as_completed(futures):\n url, data = future.result()\n results[url] = data\n\n return json.dumps(results)\n'
EXAMPLE_ASYNC_FAN_OUT = 'import json\nimport asyncio\nfrom kiln import async_tools\n\nasync def run(user_ids: list[str]) -> str:\n """Fetch user details concurrently using async_tools."""\n async def fetch_user(uid):\n result = await async_tools.get_user(id=uid)\n return json.loads(result)\n\n users = await asyncio.gather(*(fetch_user(uid) for uid in user_ids))\n return json.dumps(users)\n'
EXAMPLE_FILTER_AND_TRANSFORM = 'import json\nfrom kiln import tools\n\ndef run(query: str, max_results: int = 10) -> str:\n """Search and filter results, returning only relevant fields."""\n raw = tools.search(query=query)\n results = json.loads(raw)\n\n filtered = [\n {"title": r["title"], "url": r["url"]}\n for r in results[:max_results]\n if "title" in r and "url" in r\n ]\n\n return json.dumps(filtered)\n'
class TestUIExampleParallelWithRetries:
1264class TestUIExampleParallelWithRetries:
1265    """Validate the 'Parallel with Retries' example from the Code Tool Examples modal."""
1266
1267    @pytest.mark.asyncio
1268    async def test_parallel_with_retries_happy_path(self, tmp_path):
1269        fetch_url_responses = {
1270            "https://a.com": '{"status": "ok_a"}',
1271            "https://b.com": '{"status": "ok_b"}',
1272        }
1273        fake = FakeTool(
1274            "mcp::remote::test_server::fetch_url",
1275            "fetch_url",
1276            fn_desc="Fetch a URL",
1277            params={
1278                "type": "object",
1279                "properties": {"url": {"type": "string"}},
1280                "required": ["url"],
1281            },
1282        )
1283
1284        async def route_fetch(context=None, **kwargs):
1285            url = kwargs["url"]
1286            return ToolCallResult(output=fetch_url_responses[url])
1287
1288        fake.run = route_fetch  # type: ignore[assignment]
1289
1290        tool = _make_python_code_tool(
1291            tmp_path,
1292            EXAMPLE_PARALLEL_WITH_RETRIES,
1293            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1294            parameters_schema={
1295                "type": "object",
1296                "properties": {
1297                    "urls": {"type": "array", "items": {"type": "string"}},
1298                    "max_retries": {"type": "integer"},
1299                },
1300                "required": ["urls"],
1301            },
1302        )
1303        with patch(
1304            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1305            return_value=fake,
1306        ):
1307            result = await tool.run(
1308                None, urls=["https://a.com", "https://b.com"], max_retries=1
1309            )
1310        assert not result.is_error, f"Expected success, got: {result.output}"
1311        parsed = json.loads(result.output)
1312        assert parsed["https://a.com"] == {"status": "ok_a"}
1313        assert parsed["https://b.com"] == {"status": "ok_b"}
1314
1315    @pytest.mark.asyncio
1316    async def test_parallel_with_retries_error_fallback(self, tmp_path):
1317        """When a tool call fails, the retry logic catches the exception and
1318        returns an error dict after exhausting retries."""
1319        fake = FakeTool(
1320            "mcp::remote::test_server::fetch_url",
1321            "fetch_url",
1322            fn_desc="Fetch a URL",
1323            params={
1324                "type": "object",
1325                "properties": {"url": {"type": "string"}},
1326                "required": ["url"],
1327            },
1328            result=ToolCallResult(
1329                output="connection refused",
1330                is_error=True,
1331                error_message="connection refused",
1332            ),
1333        )
1334        tool = _make_python_code_tool(
1335            tmp_path,
1336            EXAMPLE_PARALLEL_WITH_RETRIES,
1337            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1338            parameters_schema={
1339                "type": "object",
1340                "properties": {
1341                    "urls": {"type": "array", "items": {"type": "string"}},
1342                    "max_retries": {"type": "integer"},
1343                },
1344                "required": ["urls"],
1345            },
1346        )
1347        with patch(
1348            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1349            return_value=fake,
1350        ):
1351            result = await tool.run(None, urls=["https://fail.com"], max_retries=1)
1352        assert not result.is_error, f"Expected success, got: {result.output}"
1353        parsed = json.loads(result.output)
1354        assert "error" in parsed["https://fail.com"]

Validate the 'Parallel with Retries' example from the Code Tool Examples modal.

@pytest.mark.asyncio
async def test_parallel_with_retries_happy_path(self, tmp_path):
1267    @pytest.mark.asyncio
1268    async def test_parallel_with_retries_happy_path(self, tmp_path):
1269        fetch_url_responses = {
1270            "https://a.com": '{"status": "ok_a"}',
1271            "https://b.com": '{"status": "ok_b"}',
1272        }
1273        fake = FakeTool(
1274            "mcp::remote::test_server::fetch_url",
1275            "fetch_url",
1276            fn_desc="Fetch a URL",
1277            params={
1278                "type": "object",
1279                "properties": {"url": {"type": "string"}},
1280                "required": ["url"],
1281            },
1282        )
1283
1284        async def route_fetch(context=None, **kwargs):
1285            url = kwargs["url"]
1286            return ToolCallResult(output=fetch_url_responses[url])
1287
1288        fake.run = route_fetch  # type: ignore[assignment]
1289
1290        tool = _make_python_code_tool(
1291            tmp_path,
1292            EXAMPLE_PARALLEL_WITH_RETRIES,
1293            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1294            parameters_schema={
1295                "type": "object",
1296                "properties": {
1297                    "urls": {"type": "array", "items": {"type": "string"}},
1298                    "max_retries": {"type": "integer"},
1299                },
1300                "required": ["urls"],
1301            },
1302        )
1303        with patch(
1304            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1305            return_value=fake,
1306        ):
1307            result = await tool.run(
1308                None, urls=["https://a.com", "https://b.com"], max_retries=1
1309            )
1310        assert not result.is_error, f"Expected success, got: {result.output}"
1311        parsed = json.loads(result.output)
1312        assert parsed["https://a.com"] == {"status": "ok_a"}
1313        assert parsed["https://b.com"] == {"status": "ok_b"}
@pytest.mark.asyncio
async def test_parallel_with_retries_error_fallback(self, tmp_path):
1315    @pytest.mark.asyncio
1316    async def test_parallel_with_retries_error_fallback(self, tmp_path):
1317        """When a tool call fails, the retry logic catches the exception and
1318        returns an error dict after exhausting retries."""
1319        fake = FakeTool(
1320            "mcp::remote::test_server::fetch_url",
1321            "fetch_url",
1322            fn_desc="Fetch a URL",
1323            params={
1324                "type": "object",
1325                "properties": {"url": {"type": "string"}},
1326                "required": ["url"],
1327            },
1328            result=ToolCallResult(
1329                output="connection refused",
1330                is_error=True,
1331                error_message="connection refused",
1332            ),
1333        )
1334        tool = _make_python_code_tool(
1335            tmp_path,
1336            EXAMPLE_PARALLEL_WITH_RETRIES,
1337            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1338            parameters_schema={
1339                "type": "object",
1340                "properties": {
1341                    "urls": {"type": "array", "items": {"type": "string"}},
1342                    "max_retries": {"type": "integer"},
1343                },
1344                "required": ["urls"],
1345            },
1346        )
1347        with patch(
1348            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1349            return_value=fake,
1350        ):
1351            result = await tool.run(None, urls=["https://fail.com"], max_retries=1)
1352        assert not result.is_error, f"Expected success, got: {result.output}"
1353        parsed = json.loads(result.output)
1354        assert "error" in parsed["https://fail.com"]

When a tool call fails, the retry logic catches the exception and returns an error dict after exhausting retries.

class TestUIExampleAsyncFanOut:
1357class TestUIExampleAsyncFanOut:
1358    """Validate the 'Async Fan-Out' example from the Code Tool Examples modal."""
1359
1360    @pytest.mark.asyncio
1361    async def test_async_fan_out_happy_path(self, tmp_path):
1362        user_data = {
1363            "u1": '{"name": "Alice", "id": "u1"}',
1364            "u2": '{"name": "Bob", "id": "u2"}',
1365        }
1366        fake = FakeTool(
1367            "mcp::remote::test_server::get_user",
1368            "get_user",
1369            fn_desc="Get user details",
1370            params={
1371                "type": "object",
1372                "properties": {"id": {"type": "string"}},
1373                "required": ["id"],
1374            },
1375        )
1376
1377        async def route_user(context=None, **kwargs):
1378            uid = kwargs["id"]
1379            return ToolCallResult(output=user_data[uid])
1380
1381        fake.run = route_user  # type: ignore[assignment]
1382
1383        tool = _make_python_code_tool(
1384            tmp_path,
1385            EXAMPLE_ASYNC_FAN_OUT,
1386            tool_allowlist=["mcp::remote::test_server::get_user"],
1387            parameters_schema={
1388                "type": "object",
1389                "properties": {
1390                    "user_ids": {"type": "array", "items": {"type": "string"}},
1391                },
1392                "required": ["user_ids"],
1393            },
1394        )
1395        with patch(
1396            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1397            return_value=fake,
1398        ):
1399            result = await tool.run(None, user_ids=["u1", "u2"])
1400        assert not result.is_error, f"Expected success, got: {result.output}"
1401        parsed = json.loads(result.output)
1402        assert len(parsed) == 2
1403        assert parsed[0] == {"name": "Alice", "id": "u1"}
1404        assert parsed[1] == {"name": "Bob", "id": "u2"}

Validate the 'Async Fan-Out' example from the Code Tool Examples modal.

@pytest.mark.asyncio
async def test_async_fan_out_happy_path(self, tmp_path):
1360    @pytest.mark.asyncio
1361    async def test_async_fan_out_happy_path(self, tmp_path):
1362        user_data = {
1363            "u1": '{"name": "Alice", "id": "u1"}',
1364            "u2": '{"name": "Bob", "id": "u2"}',
1365        }
1366        fake = FakeTool(
1367            "mcp::remote::test_server::get_user",
1368            "get_user",
1369            fn_desc="Get user details",
1370            params={
1371                "type": "object",
1372                "properties": {"id": {"type": "string"}},
1373                "required": ["id"],
1374            },
1375        )
1376
1377        async def route_user(context=None, **kwargs):
1378            uid = kwargs["id"]
1379            return ToolCallResult(output=user_data[uid])
1380
1381        fake.run = route_user  # type: ignore[assignment]
1382
1383        tool = _make_python_code_tool(
1384            tmp_path,
1385            EXAMPLE_ASYNC_FAN_OUT,
1386            tool_allowlist=["mcp::remote::test_server::get_user"],
1387            parameters_schema={
1388                "type": "object",
1389                "properties": {
1390                    "user_ids": {"type": "array", "items": {"type": "string"}},
1391                },
1392                "required": ["user_ids"],
1393            },
1394        )
1395        with patch(
1396            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1397            return_value=fake,
1398        ):
1399            result = await tool.run(None, user_ids=["u1", "u2"])
1400        assert not result.is_error, f"Expected success, got: {result.output}"
1401        parsed = json.loads(result.output)
1402        assert len(parsed) == 2
1403        assert parsed[0] == {"name": "Alice", "id": "u1"}
1404        assert parsed[1] == {"name": "Bob", "id": "u2"}
class TestUIExampleFilterAndTransform:
1407class TestUIExampleFilterAndTransform:
1408    """Validate the 'Filter & Transform' example from the Code Tool Examples modal."""
1409
1410    @pytest.mark.asyncio
1411    async def test_filter_and_transform_happy_path(self, tmp_path):
1412        search_results = json.dumps(
1413            [
1414                {"title": "Result 1", "url": "https://1.com", "score": 0.9},
1415                {"title": "Result 2", "url": "https://2.com", "score": 0.8},
1416                {"description": "no title or url"},
1417                {"title": "Result 3", "url": "https://3.com", "score": 0.7},
1418            ]
1419        )
1420        fake = FakeTool(
1421            "mcp::remote::test_server::search",
1422            "search",
1423            fn_desc="Search",
1424            params={
1425                "type": "object",
1426                "properties": {"query": {"type": "string"}},
1427                "required": ["query"],
1428            },
1429            result=ToolCallResult(output=search_results),
1430        )
1431        tool = _make_python_code_tool(
1432            tmp_path,
1433            EXAMPLE_FILTER_AND_TRANSFORM,
1434            tool_allowlist=["mcp::remote::test_server::search"],
1435            parameters_schema={
1436                "type": "object",
1437                "properties": {
1438                    "query": {"type": "string"},
1439                    "max_results": {"type": "integer"},
1440                },
1441                "required": ["query"],
1442            },
1443        )
1444        with patch(
1445            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1446            return_value=fake,
1447        ):
1448            result = await tool.run(None, query="test query")
1449        assert not result.is_error, f"Expected success, got: {result.output}"
1450        parsed = json.loads(result.output)
1451        assert len(parsed) == 3
1452        assert parsed[0] == {"title": "Result 1", "url": "https://1.com"}
1453        assert parsed[1] == {"title": "Result 2", "url": "https://2.com"}
1454        assert parsed[2] == {"title": "Result 3", "url": "https://3.com"}
1455
1456    @pytest.mark.asyncio
1457    async def test_filter_and_transform_respects_max_results(self, tmp_path):
1458        search_results = json.dumps(
1459            [{"title": f"R{i}", "url": f"https://{i}.com"} for i in range(20)]
1460        )
1461        fake = FakeTool(
1462            "mcp::remote::test_server::search",
1463            "search",
1464            fn_desc="Search",
1465            params={
1466                "type": "object",
1467                "properties": {"query": {"type": "string"}},
1468                "required": ["query"],
1469            },
1470            result=ToolCallResult(output=search_results),
1471        )
1472        tool = _make_python_code_tool(
1473            tmp_path,
1474            EXAMPLE_FILTER_AND_TRANSFORM,
1475            tool_allowlist=["mcp::remote::test_server::search"],
1476            parameters_schema={
1477                "type": "object",
1478                "properties": {
1479                    "query": {"type": "string"},
1480                    "max_results": {"type": "integer"},
1481                },
1482                "required": ["query"],
1483            },
1484        )
1485        with patch(
1486            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1487            return_value=fake,
1488        ):
1489            result = await tool.run(None, query="test", max_results=3)
1490        assert not result.is_error, f"Expected success, got: {result.output}"
1491        parsed = json.loads(result.output)
1492        assert len(parsed) == 3

Validate the 'Filter & Transform' example from the Code Tool Examples modal.

@pytest.mark.asyncio
async def test_filter_and_transform_happy_path(self, tmp_path):
1410    @pytest.mark.asyncio
1411    async def test_filter_and_transform_happy_path(self, tmp_path):
1412        search_results = json.dumps(
1413            [
1414                {"title": "Result 1", "url": "https://1.com", "score": 0.9},
1415                {"title": "Result 2", "url": "https://2.com", "score": 0.8},
1416                {"description": "no title or url"},
1417                {"title": "Result 3", "url": "https://3.com", "score": 0.7},
1418            ]
1419        )
1420        fake = FakeTool(
1421            "mcp::remote::test_server::search",
1422            "search",
1423            fn_desc="Search",
1424            params={
1425                "type": "object",
1426                "properties": {"query": {"type": "string"}},
1427                "required": ["query"],
1428            },
1429            result=ToolCallResult(output=search_results),
1430        )
1431        tool = _make_python_code_tool(
1432            tmp_path,
1433            EXAMPLE_FILTER_AND_TRANSFORM,
1434            tool_allowlist=["mcp::remote::test_server::search"],
1435            parameters_schema={
1436                "type": "object",
1437                "properties": {
1438                    "query": {"type": "string"},
1439                    "max_results": {"type": "integer"},
1440                },
1441                "required": ["query"],
1442            },
1443        )
1444        with patch(
1445            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1446            return_value=fake,
1447        ):
1448            result = await tool.run(None, query="test query")
1449        assert not result.is_error, f"Expected success, got: {result.output}"
1450        parsed = json.loads(result.output)
1451        assert len(parsed) == 3
1452        assert parsed[0] == {"title": "Result 1", "url": "https://1.com"}
1453        assert parsed[1] == {"title": "Result 2", "url": "https://2.com"}
1454        assert parsed[2] == {"title": "Result 3", "url": "https://3.com"}
@pytest.mark.asyncio
async def test_filter_and_transform_respects_max_results(self, tmp_path):
1456    @pytest.mark.asyncio
1457    async def test_filter_and_transform_respects_max_results(self, tmp_path):
1458        search_results = json.dumps(
1459            [{"title": f"R{i}", "url": f"https://{i}.com"} for i in range(20)]
1460        )
1461        fake = FakeTool(
1462            "mcp::remote::test_server::search",
1463            "search",
1464            fn_desc="Search",
1465            params={
1466                "type": "object",
1467                "properties": {"query": {"type": "string"}},
1468                "required": ["query"],
1469            },
1470            result=ToolCallResult(output=search_results),
1471        )
1472        tool = _make_python_code_tool(
1473            tmp_path,
1474            EXAMPLE_FILTER_AND_TRANSFORM,
1475            tool_allowlist=["mcp::remote::test_server::search"],
1476            parameters_schema={
1477                "type": "object",
1478                "properties": {
1479                    "query": {"type": "string"},
1480                    "max_results": {"type": "integer"},
1481                },
1482                "required": ["query"],
1483            },
1484        )
1485        with patch(
1486            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1487            return_value=fake,
1488        ):
1489            result = await tool.run(None, query="test", max_results=3)
1490        assert not result.is_error, f"Expected success, got: {result.output}"
1491        parsed = json.loads(result.output)
1492        assert len(parsed) == 3