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 TestBrokenAllowlistEntry:
 657    """One unresolvable allowlist entry (e.g. a deleted RAG config) must not
 658    take down the whole nested-tool surface."""
 659
 660    BROKEN_ID = "kiln_tool::rag::missing"
 661
 662    def _patch_registry(self, healthy: FakeTool):
 663        def resolver(tool_id, project=None, task=None):
 664            if tool_id == self.BROKEN_ID:
 665                raise ValueError(f"RAG config not found: {tool_id}")
 666            return healthy
 667
 668        return patch(
 669            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 670            side_effect=resolver,
 671        )
 672
 673    @pytest.mark.asyncio
 674    async def test_list_tools_shows_healthy_and_unavailable(self, tmp_path):
 675        fake = FakeTool("kiln_tool::add_numbers", "fake_add", params=EMPTY_SCHEMA)
 676        code = textwrap.dedent("""\
 677            import json
 678            from kiln import tools
 679            def run(x):
 680                return json.dumps(tools.list_tools())
 681        """)
 682        tool = _make_python_code_tool(
 683            tmp_path,
 684            code,
 685            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
 686        )
 687        with self._patch_registry(fake):
 688            result = await tool.run(None, x="test")
 689        assert not result.is_error
 690        tool_list = json.loads(result.output)
 691        assert len(tool_list) == 2
 692        by_name = {t["name"]: t for t in tool_list}
 693        assert by_name["fake_add"]["description"] == "fake"
 694        broken = by_name[self.BROKEN_ID]
 695        assert broken["description"].startswith("(unavailable:")
 696        assert "RAG config not found" in broken["description"]
 697
 698    @pytest.mark.asyncio
 699    async def test_healthy_tool_still_callable(self, tmp_path):
 700        fake = FakeTool(
 701            "kiln_tool::add_numbers",
 702            "fake_add",
 703            params=EMPTY_SCHEMA,
 704            result=ToolCallResult(output="42"),
 705        )
 706        code = textwrap.dedent("""\
 707            from kiln import tools
 708            def run(x):
 709                return tools.fake_add()
 710        """)
 711        tool = _make_python_code_tool(
 712            tmp_path,
 713            code,
 714            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
 715        )
 716        with self._patch_registry(fake):
 717            result = await tool.run(None, x="test")
 718        assert not result.is_error
 719        assert result.output == "42"
 720
 721    @pytest.mark.asyncio
 722    async def test_calling_broken_tool_reports_unavailable(self, tmp_path):
 723        fake = FakeTool("kiln_tool::add_numbers", "fake_add", params=EMPTY_SCHEMA)
 724        code = textwrap.dedent("""\
 725            from kiln.tools import ToolNotAllowed
 726            from kiln import tools
 727            def run(x):
 728                try:
 729                    tools.missing_rag()
 730                except ToolNotAllowed as e:
 731                    return f"unavailable: {e}"
 732                return "no error"
 733        """)
 734        tool = _make_python_code_tool(
 735            tmp_path,
 736            code,
 737            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
 738        )
 739        with self._patch_registry(fake):
 740            result = await tool.run(None, x="test")
 741        assert not result.is_error
 742        assert "unavailable:" in result.output
 743        assert "not available" in result.output
 744        assert "fake_add" in result.output
 745
 746
 747class TestTimeout:
 748    @pytest.mark.asyncio
 749    async def test_timeout_kills_child(self, tmp_path):
 750        tool = _make_python_code_tool(
 751            tmp_path,
 752            "import time\ndef run(x):\n    time.sleep(30)\n    return 'done'\n",
 753            timeout_seconds=1,
 754        )
 755        result = await tool.run(None, x="test")
 756        assert result.is_error
 757        assert "timed out" in result.output
 758
 759    @pytest.mark.asyncio
 760    async def test_timeout_during_nested_call(self, tmp_path):
 761        slow_fake = FakeTool(
 762            "kiln_tool::add_numbers",
 763            "fake_add",
 764            params=EMPTY_SCHEMA,
 765            result=ToolCallResult(output="42"),
 766            delay=30,
 767        )
 768        code = textwrap.dedent("""\
 769            from kiln import tools
 770            def run(x):
 771                return tools.fake_add()
 772        """)
 773        tool = _make_python_code_tool(
 774            tmp_path,
 775            code,
 776            tool_allowlist=["kiln_tool::add_numbers"],
 777            timeout_seconds=1,
 778        )
 779        with patch(
 780            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 781            return_value=slow_fake,
 782        ):
 783            result = await tool.run(None, x="test")
 784        assert result.is_error
 785        assert "timed out" in result.output
 786
 787
 788class TestNestedTimeoutKind:
 789    """The dispatcher classifies a nested code tool's timeout from the typed
 790    ``timed_out`` flag on its result, never from the error text."""
 791
 792    def _nested_code_tool(self, project, code: str, timeout_seconds: int = 10):
 793        ct = _make_code_tool(
 794            code,
 795            name="Nested Tool",
 796            tool_function_name="nested_tool",
 797            parameters_schema=EMPTY_SCHEMA,
 798            tool_allowlist=[],
 799            timeout_seconds=timeout_seconds,
 800        )
 801        ct.parent = project
 802        return PythonCodeTool(ct, project)
 803
 804    OUTER_CODE = textwrap.dedent("""\
 805        from kiln.tools import ToolCallError, ToolTimeout
 806        from kiln import tools
 807        def run(x):
 808            try:
 809                tools.nested_tool()
 810            except ToolTimeout:
 811                return "timeout"
 812            except ToolCallError:
 813                return "call_error"
 814            return "no error"
 815    """)
 816
 817    @pytest.mark.asyncio
 818    async def test_real_nested_timeout_raises_tool_timeout(self, tmp_path):
 819        project = _make_project(tmp_path)
 820        nested = self._nested_code_tool(
 821            project,
 822            "import time\ndef run():\n    time.sleep(30)\n",
 823            timeout_seconds=1,
 824        )
 825        outer_ct = _make_code_tool(
 826            self.OUTER_CODE, tool_allowlist=["kiln_tool::add_numbers"]
 827        )
 828        outer_ct.parent = project
 829        outer = PythonCodeTool(outer_ct, project)
 830        with patch(
 831            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 832            return_value=nested,
 833        ):
 834            result = await outer.run(None, x="test")
 835        assert not result.is_error
 836        assert result.output == "timeout"
 837
 838    @pytest.mark.asyncio
 839    async def test_failure_text_mentioning_timeout_raises_call_error(self, tmp_path):
 840        # An ordinary failure whose message merely contains "timed out" must
 841        # not spoof the timeout kind (which callers treat as retryable).
 842        project = _make_project(tmp_path)
 843        nested = self._nested_code_tool(
 844            project,
 845            'def run():\n    raise Exception("upstream request timed out after 3 retries")\n',
 846        )
 847        outer_ct = _make_code_tool(
 848            self.OUTER_CODE, tool_allowlist=["kiln_tool::add_numbers"]
 849        )
 850        outer_ct.parent = project
 851        outer = PythonCodeTool(outer_ct, project)
 852        with patch(
 853            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 854            return_value=nested,
 855        ):
 856            result = await outer.run(None, x="test")
 857        assert not result.is_error
 858        assert result.output == "call_error"
 859
 860
 861class TestCrash:
 862    @pytest.mark.asyncio
 863    async def test_crash_via_os_exit(self, tmp_path):
 864        tool = _make_python_code_tool(
 865            tmp_path,
 866            "import os\ndef run(x):\n    os._exit(3)\n",
 867        )
 868        result = await tool.run(None, x="test")
 869        assert result.is_error
 870        assert "crashed" in result.output
 871        assert "exit code" in result.output
 872
 873
 874class TestDepthCap:
 875    @pytest.mark.asyncio
 876    async def test_depth_cap_at_10(self, tmp_path):
 877        """Depth >= 10 returns an error without spawning."""
 878        tool = _make_python_code_tool(tmp_path, 'def run(x):\n    return "ok"\n')
 879        token = _depth.set(10)
 880        try:
 881            result = await tool.run(None, x="test")
 882        finally:
 883            _depth.reset(token)
 884        assert result.is_error
 885        assert "maximum nested code execution depth exceeded" in result.output
 886
 887
 888class TestSemaphore:
 889    @pytest.mark.asyncio
 890    async def test_semaphore_top_level_only_no_deadlock(self, tmp_path):
 891        """Regression: nested code-tool calls bypass the semaphore.
 892
 893        If nested calls counted against the semaphore, 8 parents each
 894        spawning a nested code-tool child would deadlock (parents hold
 895        all 8 slots, children wait forever).
 896
 897        This test sets MAX_CONCURRENCY parents running concurrently,
 898        each at depth 1 (simulating nested calls). All should complete
 899        without deadlock because nested calls bypass the semaphore.
 900        """
 901        code = 'def run(x):\n    return "nested_ok"\n'
 902        results = []
 903
 904        async def run_nested(i: int):
 905            tool = _make_python_code_tool(tmp_path, code)
 906            token = _depth.set(1)
 907            try:
 908                r = await tool.run(None, x=str(i))
 909                results.append(r)
 910            finally:
 911                _depth.reset(token)
 912
 913        await asyncio.gather(
 914            *(run_nested(i) for i in range(CODE_SANDBOX_MAX_CONCURRENCY))
 915        )
 916        assert len(results) == CODE_SANDBOX_MAX_CONCURRENCY
 917        assert all(not r.is_error for r in results)
 918
 919
 920class TestToolCallRecorder:
 921    @pytest.mark.asyncio
 922    async def test_recorder_gets_entries(self, tmp_path):
 923        fake = FakeTool(
 924            "kiln_tool::add_numbers",
 925            "fake_add",
 926            params=EMPTY_SCHEMA,
 927            result=ToolCallResult(output="42"),
 928        )
 929        log: list[ToolCallLogEntry] = []
 930        code = textwrap.dedent("""\
 931            from kiln import tools
 932            def run(x):
 933                r = tools.fake_add()
 934                return r
 935        """)
 936        tool = _make_python_code_tool(
 937            tmp_path,
 938            code,
 939            tool_allowlist=["kiln_tool::add_numbers"],
 940            tool_call_recorder=log.append,
 941        )
 942        with patch(
 943            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 944            return_value=fake,
 945        ):
 946            result = await tool.run(None, x="test")
 947        assert not result.is_error
 948        assert len(log) == 1
 949        assert log[0].tool_name == "fake_add"
 950        assert not log[0].is_error
 951        assert log[0].output_preview == "42"
 952        assert log[0].duration_ms >= 0
 953
 954
 955class TestAsyncToolsConcurrency:
 956    @pytest.mark.asyncio
 957    async def test_async_tools_gather_truly_concurrent(self, tmp_path):
 958        """async_tools + gather provides real parallelism via to_thread.
 959
 960        Two fake tools each take ~0.3s. If sequential, wall clock >= 0.6s.
 961        With true concurrency via gather + to_thread, wall clock < 0.6s.
 962        """
 963        slow_fake = FakeTool(
 964            "kiln_tool::add_numbers",
 965            "fake_add",
 966            params=EMPTY_SCHEMA,
 967            result=ToolCallResult(output="done"),
 968            delay=0.3,
 969        )
 970        code = textwrap.dedent("""\
 971            import asyncio, time
 972            from kiln import async_tools
 973            async def run(x):
 974                start = time.monotonic()
 975                a, b = await asyncio.gather(
 976                    async_tools.fake_add(),
 977                    async_tools.fake_add(),
 978                )
 979                elapsed = time.monotonic() - start
 980                return f"{elapsed:.2f}"
 981        """)
 982        tool = _make_python_code_tool(
 983            tmp_path,
 984            code,
 985            tool_allowlist=["kiln_tool::add_numbers"],
 986        )
 987        with patch(
 988            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
 989            return_value=slow_fake,
 990        ):
 991            result = await tool.run(None, x="test")
 992        assert not result.is_error
 993        elapsed = float(result.output)
 994        assert elapsed < 0.6, f"Expected < 0.6s (concurrent), got {elapsed:.2f}s"
 995
 996
 997class _EchoFakeTool(KilnToolInterface):
 998    """Fake tool that echoes its kwargs back, proving per-call routing."""
 999
1000    def __init__(self, tool_id: str, fn_name: str, params: dict):
1001        self._id = tool_id
1002        self._name = fn_name
1003        self._params = params
1004
1005    async def id(self):
1006        return self._id
1007
1008    async def name(self):
1009        return self._name
1010
1011    async def description(self):
1012        return "echo"
1013
1014    async def toolcall_definition(self) -> ToolCallDefinition:
1015        return {
1016            "type": "function",
1017            "function": {
1018                "name": self._name,
1019                "description": "echo",
1020                "parameters": self._params,
1021            },
1022        }
1023
1024    async def run(self, context=None, **kwargs) -> ToolCallResult:
1025        return ToolCallResult(output=json.dumps(kwargs, sort_keys=True))
1026
1027
1028class TestCallIdRouting:
1029    @pytest.mark.asyncio
1030    async def test_concurrent_calls_routed_to_correct_caller(self, tmp_path):
1031        """4 threads each pass a unique idx kwarg; each gets its own value back.
1032
1033        The echo tool returns the kwargs it received. Each thread asserts it
1034        got back the idx it sent, proving call_id routing maps the right
1035        response to the right waiting caller under concurrency.
1036        """
1037        idx_schema = {
1038            "type": "object",
1039            "properties": {"idx": {"type": "string"}},
1040            "required": ["idx"],
1041        }
1042        code = textwrap.dedent("""\
1043            import json, threading
1044            from kiln import tools
1045            def run(x):
1046                results = [None] * 4
1047                errors = []
1048                def call_tool(i):
1049                    try:
1050                        raw = tools.fake_echo(idx=str(i))
1051                        results[i] = json.loads(raw)["idx"]
1052                    except Exception as e:
1053                        errors.append(f"thread {i}: {e}")
1054                threads = [threading.Thread(target=call_tool, args=(i,)) for i in range(4)]
1055                for t in threads:
1056                    t.start()
1057                for t in threads:
1058                    t.join()
1059                if errors:
1060                    return "errors: " + str(errors)
1061                return ",".join(results)
1062        """)
1063        fake = _EchoFakeTool(
1064            "kiln_tool::add_numbers",
1065            "fake_echo",
1066            params=idx_schema,
1067        )
1068        tool = _make_python_code_tool(
1069            tmp_path,
1070            code,
1071            tool_allowlist=["kiln_tool::add_numbers"],
1072        )
1073        with patch(
1074            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1075            return_value=fake,
1076        ):
1077            result = await tool.run(None, x="test")
1078        assert not result.is_error
1079        parts = result.output.split(",")
1080        assert len(parts) == 4
1081        assert parts == ["0", "1", "2", "3"]
1082
1083
1084class TestUnicodePassthrough:
1085    @pytest.mark.asyncio
1086    async def test_unicode_not_escaped(self, tmp_path):
1087        """ensure_ascii=False: non-ASCII chars pass through un-escaped."""
1088        tool = _make_python_code_tool(
1089            tmp_path,
1090            'def run(x):\n    return {"name": "\\u65e5\\u672c\\u8a9e"}\n',
1091        )
1092        result = await tool.run(None, x="test")
1093        assert not result.is_error
1094        parsed = json.loads(result.output)
1095        assert parsed["name"] == "日本語"
1096        assert "\\u" not in result.output
1097
1098
1099class TestSpawnLockIdentity:
1100    def test_spawn_lock_shared(self):
1101        """Code tools and code evals spawn through the same bridge / _spawn_lock."""
1102        from kiln_ai.tools import sandbox_bridge
1103
1104        assert (
1105            sandbox_bridge.start_process_with_light_main.__module__
1106            == "kiln_ai.sandbox.spawn"
1107        )
1108        from kiln_ai.sandbox.spawn import _spawn_lock as shared_lock
1109
1110        assert shared_lock is _spawn_lock
1111
1112
1113# ---------------------------------------------------------------------------
1114# End-to-end tests using REAL built-in tools (no mocking)
1115# ---------------------------------------------------------------------------
1116
1117
1118class TestRealBuiltInTools:
1119    """Tests that exercise real built-in tool dispatch without mocking
1120    ``tool_from_id_and_project``, ensuring the name-derivation path is
1121    exercised end-to-end.
1122    """
1123
1124    @pytest.mark.asyncio
1125    async def test_keyword_call_by_canonical_name_succeeds(self, tmp_path):
1126        """tools.add(a=1, b=2) returns '3' — the canonical name from list_tools."""
1127        code = textwrap.dedent("""\
1128            from kiln import tools
1129            def run(x):
1130                return tools.add(a=1, b=2)
1131        """)
1132        tool = _make_python_code_tool(
1133            tmp_path,
1134            code,
1135            tool_allowlist=["kiln_tool::add_numbers"],
1136        )
1137        result = await tool.run(None, x="test")
1138        assert not result.is_error, f"Expected success, got: {result.output}"
1139        assert result.output == "3"
1140
1141    @pytest.mark.asyncio
1142    async def test_list_tools_driven_call_succeeds(self, tmp_path):
1143        """Call using the name returned by list_tools() succeeds."""
1144        code = textwrap.dedent("""\
1145            from kiln import tools
1146            def run(x):
1147                tl = tools.list_tools()
1148                fn_name = tl[0]["name"]
1149                result = getattr(tools, fn_name)(a=5, b=3)
1150                return fn_name + ":" + result
1151        """)
1152        tool = _make_python_code_tool(
1153            tmp_path,
1154            code,
1155            tool_allowlist=["kiln_tool::add_numbers"],
1156        )
1157        result = await tool.run(None, x="test")
1158        assert not result.is_error, f"Expected success, got: {result.output}"
1159        assert result.output == "add:8"
1160
1161    @pytest.mark.asyncio
1162    async def test_friendly_name_not_allowed(self, tmp_path):
1163        """tools.Addition(a=1,b=2) raises ToolNotAllowed listing canonical names."""
1164        code = textwrap.dedent("""\
1165            from kiln.tools import ToolNotAllowed
1166            from kiln import tools
1167            def run(x):
1168                try:
1169                    tools.Addition(a=1, b=2)
1170                except ToolNotAllowed as e:
1171                    return e.message
1172                return "no error"
1173        """)
1174        tool = _make_python_code_tool(
1175            tmp_path,
1176            code,
1177            tool_allowlist=["kiln_tool::add_numbers"],
1178        )
1179        result = await tool.run(None, x="test")
1180        assert not result.is_error
1181        assert "not available" in result.output
1182        assert "'add'" in result.output
1183
1184    @pytest.mark.asyncio
1185    async def test_nonsense_name_not_allowed(self, tmp_path):
1186        """tools.bad_tool() raises ToolNotAllowed listing available names."""
1187        code = textwrap.dedent("""\
1188            from kiln.tools import ToolNotAllowed
1189            from kiln import tools
1190            def run(x):
1191                try:
1192                    tools.bad_tool(a=1)
1193                except ToolNotAllowed as e:
1194                    return e.message
1195                return "no error"
1196        """)
1197        tool = _make_python_code_tool(
1198            tmp_path,
1199            code,
1200            tool_allowlist=["kiln_tool::add_numbers"],
1201        )
1202        result = await tool.run(None, x="test")
1203        assert not result.is_error
1204        assert "not available" in result.output
1205        assert "'add'" in result.output
1206
1207    @pytest.mark.asyncio
1208    async def test_positional_args_error_message(self, tmp_path):
1209        """tools.add(1, 2) raises ToolCallError mentioning keyword args and params."""
1210        code = textwrap.dedent("""\
1211            from kiln.tools import ToolCallError
1212            from kiln import tools
1213            def run(x):
1214                try:
1215                    tools.add(1, 2)
1216                except ToolCallError as e:
1217                    return e.message
1218                return "no error"
1219        """)
1220        tool = _make_python_code_tool(
1221            tmp_path,
1222            code,
1223            tool_allowlist=["kiln_tool::add_numbers"],
1224        )
1225        result = await tool.run(None, x="test")
1226        assert not result.is_error
1227        assert "keyword arguments" in result.output
1228        assert "tools.add(" in result.output
1229        assert "a: number (required)" in result.output
1230        assert "b: number (required)" in result.output
1231
1232    @pytest.mark.asyncio
1233    async def test_wrong_kwargs_error_shows_schema(self, tmp_path):
1234        """tools.add(x=1) raises ToolCallError showing expected parameters."""
1235        code = textwrap.dedent("""\
1236            from kiln.tools import ToolCallError
1237            from kiln import tools
1238            def run(x):
1239                try:
1240                    tools.add(x=1)
1241                except ToolCallError as e:
1242                    return e.message
1243                return "no error"
1244        """)
1245        tool = _make_python_code_tool(
1246            tmp_path,
1247            code,
1248            tool_allowlist=["kiln_tool::add_numbers"],
1249        )
1250        result = await tool.run(None, x="test")
1251        assert not result.is_error
1252        assert "Expected parameters:" in result.output
1253        assert "a: number (required)" in result.output
1254
1255    @pytest.mark.asyncio
1256    async def test_name_consistency_across_all_builtins(self, tmp_path):
1257        """For every KilnBuiltInToolId the dispatch-map name matches tool.name()
1258        AND matches what list_tools reports."""
1259        from kiln_ai.datamodel.tool_id import KilnBuiltInToolId
1260        from kiln_ai.tools.tool_registry import tool_from_id_and_project
1261
1262        project = _make_project(tmp_path)
1263
1264        math_ids = [
1265            KilnBuiltInToolId.ADD_NUMBERS,
1266            KilnBuiltInToolId.SUBTRACT_NUMBERS,
1267            KilnBuiltInToolId.MULTIPLY_NUMBERS,
1268            KilnBuiltInToolId.DIVIDE_NUMBERS,
1269        ]
1270
1271        for builtin_id in math_ids:
1272            tool_id = builtin_id.value
1273            real_tool = tool_from_id_and_project(tool_id, project=project)
1274            real_name = await real_tool.name()
1275
1276            ct = _make_code_tool(
1277                'def run(x): return "ok"',
1278                tool_allowlist=[tool_id],
1279            )
1280            ct.parent = project
1281            server = NestedToolServer(
1282                allowlist=ct.tool_allowlist, project=project, task=None, context=None
1283            )
1284            dispatch_names = list((await server.name_map()).keys())
1285
1286            assert dispatch_names == [real_name], (
1287                f"For {builtin_id}: dispatch name {dispatch_names} != "
1288                f"tool.name() '{real_name}'"
1289            )
1290
1291    @pytest.mark.asyncio
1292    async def test_async_proxy_keyword_call(self, tmp_path):
1293        """async_tools.subtract(a=5, b=3) returns '2'."""
1294        code = textwrap.dedent("""\
1295            from kiln import async_tools
1296            async def run(x):
1297                return await async_tools.subtract(a=5, b=3)
1298        """)
1299        tool = _make_python_code_tool(
1300            tmp_path,
1301            code,
1302            tool_allowlist=["kiln_tool::subtract_numbers"],
1303        )
1304        result = await tool.run(None, x="test")
1305        assert not result.is_error, f"Expected success, got: {result.output}"
1306        assert result.output == "2"
1307
1308    @pytest.mark.asyncio
1309    async def test_async_proxy_positional_error(self, tmp_path):
1310        """async_tools.add(1, 2) raises ToolCallError with a helpful message."""
1311        code = textwrap.dedent("""\
1312            from kiln.tools import ToolCallError
1313            from kiln import async_tools
1314            async def run(x):
1315                try:
1316                    await async_tools.add(1, 2)
1317                except ToolCallError as e:
1318                    return e.message
1319                return "no error"
1320        """)
1321        tool = _make_python_code_tool(
1322            tmp_path,
1323            code,
1324            tool_allowlist=["kiln_tool::add_numbers"],
1325        )
1326        result = await tool.run(None, x="test")
1327        assert not result.is_error
1328        assert "keyword arguments" in result.output
1329
1330    @pytest.mark.asyncio
1331    async def test_positional_on_nonsense_name_still_not_allowed(self, tmp_path):
1332        """tools.bad_tool(1) raises ToolNotAllowed (not TypeError), regardless of args."""
1333        code = textwrap.dedent("""\
1334            from kiln.tools import ToolNotAllowed
1335            from kiln import tools
1336            def run(x):
1337                try:
1338                    tools.bad_tool(1, 2)
1339                except ToolNotAllowed as e:
1340                    return e.message
1341                return "no error"
1342        """)
1343        tool = _make_python_code_tool(
1344            tmp_path,
1345            code,
1346            tool_allowlist=["kiln_tool::add_numbers"],
1347        )
1348        result = await tool.run(None, x="test")
1349        assert not result.is_error
1350        assert "not available" in result.output
1351
1352
1353# ---------------------------------------------------------------------------
1354# UI example validation tests
1355#
1356# These tests execute the EXACT code strings shown in the "Code Tool Examples"
1357# modal (app/web_ui/src/lib/utils/code_tool_helpers.ts → generateExamples()).
1358# If you change those examples, you MUST update these tests to match.
1359# ---------------------------------------------------------------------------
1360
1361# The example code strings are duplicated here intentionally so any drift
1362# between the UI and these tests causes a test failure during review.
1363
1364EXAMPLE_PARALLEL_WITH_RETRIES = """\
1365import json
1366import time
1367from concurrent.futures import ThreadPoolExecutor, as_completed
1368from kiln import tools
1369
1370def run(urls: list[str], max_retries: int = 3) -> str:
1371    \"\"\"Fetch multiple URLs in parallel with retries.\"\"\"
1372    results = {}
1373
1374    def fetch_with_retry(url):
1375        for attempt in range(max_retries):
1376            try:
1377                result = tools.fetch_url(url=url)
1378                return url, json.loads(result)
1379            except Exception as e:
1380                if attempt == max_retries - 1:
1381                    return url, {"error": str(e)}
1382                time.sleep(0.5 * (attempt + 1))
1383
1384    with ThreadPoolExecutor(max_workers=5) as pool:
1385        futures = [pool.submit(fetch_with_retry, u) for u in urls]
1386        for future in as_completed(futures):
1387            url, data = future.result()
1388            results[url] = data
1389
1390    return json.dumps(results)
1391"""
1392
1393EXAMPLE_ASYNC_FAN_OUT = """\
1394import json
1395import asyncio
1396from kiln import async_tools
1397
1398async def run(user_ids: list[str]) -> str:
1399    \"\"\"Fetch user details concurrently using async_tools.\"\"\"
1400    async def fetch_user(uid):
1401        result = await async_tools.get_user(id=uid)
1402        return json.loads(result)
1403
1404    users = await asyncio.gather(*(fetch_user(uid) for uid in user_ids))
1405    return json.dumps(users)
1406"""
1407
1408EXAMPLE_FILTER_AND_TRANSFORM = """\
1409import json
1410from kiln import tools
1411
1412def run(query: str, max_results: int = 10) -> str:
1413    \"\"\"Search and filter results, returning only relevant fields.\"\"\"
1414    raw = tools.search(query=query)
1415    results = json.loads(raw)
1416
1417    filtered = [
1418        {"title": r["title"], "url": r["url"]}
1419        for r in results[:max_results]
1420        if "title" in r and "url" in r
1421    ]
1422
1423    return json.dumps(filtered)
1424"""
1425
1426
1427class TestUIExampleParallelWithRetries:
1428    """Validate the 'Parallel with Retries' example from the Code Tool Examples modal."""
1429
1430    @pytest.mark.asyncio
1431    async def test_parallel_with_retries_happy_path(self, tmp_path):
1432        fetch_url_responses = {
1433            "https://a.com": '{"status": "ok_a"}',
1434            "https://b.com": '{"status": "ok_b"}',
1435        }
1436        fake = FakeTool(
1437            "mcp::remote::test_server::fetch_url",
1438            "fetch_url",
1439            fn_desc="Fetch a URL",
1440            params={
1441                "type": "object",
1442                "properties": {"url": {"type": "string"}},
1443                "required": ["url"],
1444            },
1445        )
1446
1447        async def route_fetch(context=None, **kwargs):
1448            url = kwargs["url"]
1449            return ToolCallResult(output=fetch_url_responses[url])
1450
1451        fake.run = route_fetch  # type: ignore[assignment]
1452
1453        tool = _make_python_code_tool(
1454            tmp_path,
1455            EXAMPLE_PARALLEL_WITH_RETRIES,
1456            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1457            parameters_schema={
1458                "type": "object",
1459                "properties": {
1460                    "urls": {"type": "array", "items": {"type": "string"}},
1461                    "max_retries": {"type": "integer"},
1462                },
1463                "required": ["urls"],
1464            },
1465        )
1466        with patch(
1467            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1468            return_value=fake,
1469        ):
1470            result = await tool.run(
1471                None, urls=["https://a.com", "https://b.com"], max_retries=1
1472            )
1473        assert not result.is_error, f"Expected success, got: {result.output}"
1474        parsed = json.loads(result.output)
1475        assert parsed["https://a.com"] == {"status": "ok_a"}
1476        assert parsed["https://b.com"] == {"status": "ok_b"}
1477
1478    @pytest.mark.asyncio
1479    async def test_parallel_with_retries_error_fallback(self, tmp_path):
1480        """When a tool call fails, the retry logic catches the exception and
1481        returns an error dict after exhausting retries."""
1482        fake = FakeTool(
1483            "mcp::remote::test_server::fetch_url",
1484            "fetch_url",
1485            fn_desc="Fetch a URL",
1486            params={
1487                "type": "object",
1488                "properties": {"url": {"type": "string"}},
1489                "required": ["url"],
1490            },
1491            result=ToolCallResult(
1492                output="connection refused",
1493                is_error=True,
1494                error_message="connection refused",
1495            ),
1496        )
1497        tool = _make_python_code_tool(
1498            tmp_path,
1499            EXAMPLE_PARALLEL_WITH_RETRIES,
1500            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1501            parameters_schema={
1502                "type": "object",
1503                "properties": {
1504                    "urls": {"type": "array", "items": {"type": "string"}},
1505                    "max_retries": {"type": "integer"},
1506                },
1507                "required": ["urls"],
1508            },
1509        )
1510        with patch(
1511            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1512            return_value=fake,
1513        ):
1514            result = await tool.run(None, urls=["https://fail.com"], max_retries=1)
1515        assert not result.is_error, f"Expected success, got: {result.output}"
1516        parsed = json.loads(result.output)
1517        assert "error" in parsed["https://fail.com"]
1518
1519
1520class TestUIExampleAsyncFanOut:
1521    """Validate the 'Async Fan-Out' example from the Code Tool Examples modal."""
1522
1523    @pytest.mark.asyncio
1524    async def test_async_fan_out_happy_path(self, tmp_path):
1525        user_data = {
1526            "u1": '{"name": "Alice", "id": "u1"}',
1527            "u2": '{"name": "Bob", "id": "u2"}',
1528        }
1529        fake = FakeTool(
1530            "mcp::remote::test_server::get_user",
1531            "get_user",
1532            fn_desc="Get user details",
1533            params={
1534                "type": "object",
1535                "properties": {"id": {"type": "string"}},
1536                "required": ["id"],
1537            },
1538        )
1539
1540        async def route_user(context=None, **kwargs):
1541            uid = kwargs["id"]
1542            return ToolCallResult(output=user_data[uid])
1543
1544        fake.run = route_user  # type: ignore[assignment]
1545
1546        tool = _make_python_code_tool(
1547            tmp_path,
1548            EXAMPLE_ASYNC_FAN_OUT,
1549            tool_allowlist=["mcp::remote::test_server::get_user"],
1550            parameters_schema={
1551                "type": "object",
1552                "properties": {
1553                    "user_ids": {"type": "array", "items": {"type": "string"}},
1554                },
1555                "required": ["user_ids"],
1556            },
1557        )
1558        with patch(
1559            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1560            return_value=fake,
1561        ):
1562            result = await tool.run(None, user_ids=["u1", "u2"])
1563        assert not result.is_error, f"Expected success, got: {result.output}"
1564        parsed = json.loads(result.output)
1565        assert len(parsed) == 2
1566        assert parsed[0] == {"name": "Alice", "id": "u1"}
1567        assert parsed[1] == {"name": "Bob", "id": "u2"}
1568
1569
1570class TestUIExampleFilterAndTransform:
1571    """Validate the 'Filter & Transform' example from the Code Tool Examples modal."""
1572
1573    @pytest.mark.asyncio
1574    async def test_filter_and_transform_happy_path(self, tmp_path):
1575        search_results = json.dumps(
1576            [
1577                {"title": "Result 1", "url": "https://1.com", "score": 0.9},
1578                {"title": "Result 2", "url": "https://2.com", "score": 0.8},
1579                {"description": "no title or url"},
1580                {"title": "Result 3", "url": "https://3.com", "score": 0.7},
1581            ]
1582        )
1583        fake = FakeTool(
1584            "mcp::remote::test_server::search",
1585            "search",
1586            fn_desc="Search",
1587            params={
1588                "type": "object",
1589                "properties": {"query": {"type": "string"}},
1590                "required": ["query"],
1591            },
1592            result=ToolCallResult(output=search_results),
1593        )
1594        tool = _make_python_code_tool(
1595            tmp_path,
1596            EXAMPLE_FILTER_AND_TRANSFORM,
1597            tool_allowlist=["mcp::remote::test_server::search"],
1598            parameters_schema={
1599                "type": "object",
1600                "properties": {
1601                    "query": {"type": "string"},
1602                    "max_results": {"type": "integer"},
1603                },
1604                "required": ["query"],
1605            },
1606        )
1607        with patch(
1608            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1609            return_value=fake,
1610        ):
1611            result = await tool.run(None, query="test query")
1612        assert not result.is_error, f"Expected success, got: {result.output}"
1613        parsed = json.loads(result.output)
1614        assert len(parsed) == 3
1615        assert parsed[0] == {"title": "Result 1", "url": "https://1.com"}
1616        assert parsed[1] == {"title": "Result 2", "url": "https://2.com"}
1617        assert parsed[2] == {"title": "Result 3", "url": "https://3.com"}
1618
1619    @pytest.mark.asyncio
1620    async def test_filter_and_transform_respects_max_results(self, tmp_path):
1621        search_results = json.dumps(
1622            [{"title": f"R{i}", "url": f"https://{i}.com"} for i in range(20)]
1623        )
1624        fake = FakeTool(
1625            "mcp::remote::test_server::search",
1626            "search",
1627            fn_desc="Search",
1628            params={
1629                "type": "object",
1630                "properties": {"query": {"type": "string"}},
1631                "required": ["query"],
1632            },
1633            result=ToolCallResult(output=search_results),
1634        )
1635        tool = _make_python_code_tool(
1636            tmp_path,
1637            EXAMPLE_FILTER_AND_TRANSFORM,
1638            tool_allowlist=["mcp::remote::test_server::search"],
1639            parameters_schema={
1640                "type": "object",
1641                "properties": {
1642                    "query": {"type": "string"},
1643                    "max_results": {"type": "integer"},
1644                },
1645                "required": ["query"],
1646            },
1647        )
1648        with patch(
1649            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1650            return_value=fake,
1651        ):
1652            result = await tool.run(None, query="test", max_results=3)
1653        assert not result.is_error, f"Expected success, got: {result.output}"
1654        parsed = json.loads(result.output)
1655        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 TestBrokenAllowlistEntry:
657class TestBrokenAllowlistEntry:
658    """One unresolvable allowlist entry (e.g. a deleted RAG config) must not
659    take down the whole nested-tool surface."""
660
661    BROKEN_ID = "kiln_tool::rag::missing"
662
663    def _patch_registry(self, healthy: FakeTool):
664        def resolver(tool_id, project=None, task=None):
665            if tool_id == self.BROKEN_ID:
666                raise ValueError(f"RAG config not found: {tool_id}")
667            return healthy
668
669        return patch(
670            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
671            side_effect=resolver,
672        )
673
674    @pytest.mark.asyncio
675    async def test_list_tools_shows_healthy_and_unavailable(self, tmp_path):
676        fake = FakeTool("kiln_tool::add_numbers", "fake_add", params=EMPTY_SCHEMA)
677        code = textwrap.dedent("""\
678            import json
679            from kiln import tools
680            def run(x):
681                return json.dumps(tools.list_tools())
682        """)
683        tool = _make_python_code_tool(
684            tmp_path,
685            code,
686            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
687        )
688        with self._patch_registry(fake):
689            result = await tool.run(None, x="test")
690        assert not result.is_error
691        tool_list = json.loads(result.output)
692        assert len(tool_list) == 2
693        by_name = {t["name"]: t for t in tool_list}
694        assert by_name["fake_add"]["description"] == "fake"
695        broken = by_name[self.BROKEN_ID]
696        assert broken["description"].startswith("(unavailable:")
697        assert "RAG config not found" in broken["description"]
698
699    @pytest.mark.asyncio
700    async def test_healthy_tool_still_callable(self, tmp_path):
701        fake = FakeTool(
702            "kiln_tool::add_numbers",
703            "fake_add",
704            params=EMPTY_SCHEMA,
705            result=ToolCallResult(output="42"),
706        )
707        code = textwrap.dedent("""\
708            from kiln import tools
709            def run(x):
710                return tools.fake_add()
711        """)
712        tool = _make_python_code_tool(
713            tmp_path,
714            code,
715            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
716        )
717        with self._patch_registry(fake):
718            result = await tool.run(None, x="test")
719        assert not result.is_error
720        assert result.output == "42"
721
722    @pytest.mark.asyncio
723    async def test_calling_broken_tool_reports_unavailable(self, tmp_path):
724        fake = FakeTool("kiln_tool::add_numbers", "fake_add", params=EMPTY_SCHEMA)
725        code = textwrap.dedent("""\
726            from kiln.tools import ToolNotAllowed
727            from kiln import tools
728            def run(x):
729                try:
730                    tools.missing_rag()
731                except ToolNotAllowed as e:
732                    return f"unavailable: {e}"
733                return "no error"
734        """)
735        tool = _make_python_code_tool(
736            tmp_path,
737            code,
738            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
739        )
740        with self._patch_registry(fake):
741            result = await tool.run(None, x="test")
742        assert not result.is_error
743        assert "unavailable:" in result.output
744        assert "not available" in result.output
745        assert "fake_add" in result.output

One unresolvable allowlist entry (e.g. a deleted RAG config) must not take down the whole nested-tool surface.

BROKEN_ID = 'kiln_tool::rag::missing'
@pytest.mark.asyncio
async def test_list_tools_shows_healthy_and_unavailable(self, tmp_path):
674    @pytest.mark.asyncio
675    async def test_list_tools_shows_healthy_and_unavailable(self, tmp_path):
676        fake = FakeTool("kiln_tool::add_numbers", "fake_add", params=EMPTY_SCHEMA)
677        code = textwrap.dedent("""\
678            import json
679            from kiln import tools
680            def run(x):
681                return json.dumps(tools.list_tools())
682        """)
683        tool = _make_python_code_tool(
684            tmp_path,
685            code,
686            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
687        )
688        with self._patch_registry(fake):
689            result = await tool.run(None, x="test")
690        assert not result.is_error
691        tool_list = json.loads(result.output)
692        assert len(tool_list) == 2
693        by_name = {t["name"]: t for t in tool_list}
694        assert by_name["fake_add"]["description"] == "fake"
695        broken = by_name[self.BROKEN_ID]
696        assert broken["description"].startswith("(unavailable:")
697        assert "RAG config not found" in broken["description"]
@pytest.mark.asyncio
async def test_healthy_tool_still_callable(self, tmp_path):
699    @pytest.mark.asyncio
700    async def test_healthy_tool_still_callable(self, tmp_path):
701        fake = FakeTool(
702            "kiln_tool::add_numbers",
703            "fake_add",
704            params=EMPTY_SCHEMA,
705            result=ToolCallResult(output="42"),
706        )
707        code = textwrap.dedent("""\
708            from kiln import tools
709            def run(x):
710                return tools.fake_add()
711        """)
712        tool = _make_python_code_tool(
713            tmp_path,
714            code,
715            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
716        )
717        with self._patch_registry(fake):
718            result = await tool.run(None, x="test")
719        assert not result.is_error
720        assert result.output == "42"
@pytest.mark.asyncio
async def test_calling_broken_tool_reports_unavailable(self, tmp_path):
722    @pytest.mark.asyncio
723    async def test_calling_broken_tool_reports_unavailable(self, tmp_path):
724        fake = FakeTool("kiln_tool::add_numbers", "fake_add", params=EMPTY_SCHEMA)
725        code = textwrap.dedent("""\
726            from kiln.tools import ToolNotAllowed
727            from kiln import tools
728            def run(x):
729                try:
730                    tools.missing_rag()
731                except ToolNotAllowed as e:
732                    return f"unavailable: {e}"
733                return "no error"
734        """)
735        tool = _make_python_code_tool(
736            tmp_path,
737            code,
738            tool_allowlist=["kiln_tool::add_numbers", self.BROKEN_ID],
739        )
740        with self._patch_registry(fake):
741            result = await tool.run(None, x="test")
742        assert not result.is_error
743        assert "unavailable:" in result.output
744        assert "not available" in result.output
745        assert "fake_add" in result.output
class TestTimeout:
748class TestTimeout:
749    @pytest.mark.asyncio
750    async def test_timeout_kills_child(self, tmp_path):
751        tool = _make_python_code_tool(
752            tmp_path,
753            "import time\ndef run(x):\n    time.sleep(30)\n    return 'done'\n",
754            timeout_seconds=1,
755        )
756        result = await tool.run(None, x="test")
757        assert result.is_error
758        assert "timed out" in result.output
759
760    @pytest.mark.asyncio
761    async def test_timeout_during_nested_call(self, tmp_path):
762        slow_fake = FakeTool(
763            "kiln_tool::add_numbers",
764            "fake_add",
765            params=EMPTY_SCHEMA,
766            result=ToolCallResult(output="42"),
767            delay=30,
768        )
769        code = textwrap.dedent("""\
770            from kiln import tools
771            def run(x):
772                return tools.fake_add()
773        """)
774        tool = _make_python_code_tool(
775            tmp_path,
776            code,
777            tool_allowlist=["kiln_tool::add_numbers"],
778            timeout_seconds=1,
779        )
780        with patch(
781            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
782            return_value=slow_fake,
783        ):
784            result = await tool.run(None, x="test")
785        assert result.is_error
786        assert "timed out" in result.output
@pytest.mark.asyncio
async def test_timeout_kills_child(self, tmp_path):
749    @pytest.mark.asyncio
750    async def test_timeout_kills_child(self, tmp_path):
751        tool = _make_python_code_tool(
752            tmp_path,
753            "import time\ndef run(x):\n    time.sleep(30)\n    return 'done'\n",
754            timeout_seconds=1,
755        )
756        result = await tool.run(None, x="test")
757        assert result.is_error
758        assert "timed out" in result.output
@pytest.mark.asyncio
async def test_timeout_during_nested_call(self, tmp_path):
760    @pytest.mark.asyncio
761    async def test_timeout_during_nested_call(self, tmp_path):
762        slow_fake = FakeTool(
763            "kiln_tool::add_numbers",
764            "fake_add",
765            params=EMPTY_SCHEMA,
766            result=ToolCallResult(output="42"),
767            delay=30,
768        )
769        code = textwrap.dedent("""\
770            from kiln import tools
771            def run(x):
772                return tools.fake_add()
773        """)
774        tool = _make_python_code_tool(
775            tmp_path,
776            code,
777            tool_allowlist=["kiln_tool::add_numbers"],
778            timeout_seconds=1,
779        )
780        with patch(
781            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
782            return_value=slow_fake,
783        ):
784            result = await tool.run(None, x="test")
785        assert result.is_error
786        assert "timed out" in result.output
class TestNestedTimeoutKind:
789class TestNestedTimeoutKind:
790    """The dispatcher classifies a nested code tool's timeout from the typed
791    ``timed_out`` flag on its result, never from the error text."""
792
793    def _nested_code_tool(self, project, code: str, timeout_seconds: int = 10):
794        ct = _make_code_tool(
795            code,
796            name="Nested Tool",
797            tool_function_name="nested_tool",
798            parameters_schema=EMPTY_SCHEMA,
799            tool_allowlist=[],
800            timeout_seconds=timeout_seconds,
801        )
802        ct.parent = project
803        return PythonCodeTool(ct, project)
804
805    OUTER_CODE = textwrap.dedent("""\
806        from kiln.tools import ToolCallError, ToolTimeout
807        from kiln import tools
808        def run(x):
809            try:
810                tools.nested_tool()
811            except ToolTimeout:
812                return "timeout"
813            except ToolCallError:
814                return "call_error"
815            return "no error"
816    """)
817
818    @pytest.mark.asyncio
819    async def test_real_nested_timeout_raises_tool_timeout(self, tmp_path):
820        project = _make_project(tmp_path)
821        nested = self._nested_code_tool(
822            project,
823            "import time\ndef run():\n    time.sleep(30)\n",
824            timeout_seconds=1,
825        )
826        outer_ct = _make_code_tool(
827            self.OUTER_CODE, tool_allowlist=["kiln_tool::add_numbers"]
828        )
829        outer_ct.parent = project
830        outer = PythonCodeTool(outer_ct, project)
831        with patch(
832            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
833            return_value=nested,
834        ):
835            result = await outer.run(None, x="test")
836        assert not result.is_error
837        assert result.output == "timeout"
838
839    @pytest.mark.asyncio
840    async def test_failure_text_mentioning_timeout_raises_call_error(self, tmp_path):
841        # An ordinary failure whose message merely contains "timed out" must
842        # not spoof the timeout kind (which callers treat as retryable).
843        project = _make_project(tmp_path)
844        nested = self._nested_code_tool(
845            project,
846            'def run():\n    raise Exception("upstream request timed out after 3 retries")\n',
847        )
848        outer_ct = _make_code_tool(
849            self.OUTER_CODE, tool_allowlist=["kiln_tool::add_numbers"]
850        )
851        outer_ct.parent = project
852        outer = PythonCodeTool(outer_ct, project)
853        with patch(
854            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
855            return_value=nested,
856        ):
857            result = await outer.run(None, x="test")
858        assert not result.is_error
859        assert result.output == "call_error"

The dispatcher classifies a nested code tool's timeout from the typed timed_out flag on its result, never from the error text.

OUTER_CODE = 'from kiln.tools import ToolCallError, ToolTimeout\nfrom kiln import tools\ndef run(x):\n try:\n tools.nested_tool()\n except ToolTimeout:\n return "timeout"\n except ToolCallError:\n return "call_error"\n return "no error"\n'
@pytest.mark.asyncio
async def test_real_nested_timeout_raises_tool_timeout(self, tmp_path):
818    @pytest.mark.asyncio
819    async def test_real_nested_timeout_raises_tool_timeout(self, tmp_path):
820        project = _make_project(tmp_path)
821        nested = self._nested_code_tool(
822            project,
823            "import time\ndef run():\n    time.sleep(30)\n",
824            timeout_seconds=1,
825        )
826        outer_ct = _make_code_tool(
827            self.OUTER_CODE, tool_allowlist=["kiln_tool::add_numbers"]
828        )
829        outer_ct.parent = project
830        outer = PythonCodeTool(outer_ct, project)
831        with patch(
832            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
833            return_value=nested,
834        ):
835            result = await outer.run(None, x="test")
836        assert not result.is_error
837        assert result.output == "timeout"
@pytest.mark.asyncio
async def test_failure_text_mentioning_timeout_raises_call_error(self, tmp_path):
839    @pytest.mark.asyncio
840    async def test_failure_text_mentioning_timeout_raises_call_error(self, tmp_path):
841        # An ordinary failure whose message merely contains "timed out" must
842        # not spoof the timeout kind (which callers treat as retryable).
843        project = _make_project(tmp_path)
844        nested = self._nested_code_tool(
845            project,
846            'def run():\n    raise Exception("upstream request timed out after 3 retries")\n',
847        )
848        outer_ct = _make_code_tool(
849            self.OUTER_CODE, tool_allowlist=["kiln_tool::add_numbers"]
850        )
851        outer_ct.parent = project
852        outer = PythonCodeTool(outer_ct, project)
853        with patch(
854            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
855            return_value=nested,
856        ):
857            result = await outer.run(None, x="test")
858        assert not result.is_error
859        assert result.output == "call_error"
class TestCrash:
862class TestCrash:
863    @pytest.mark.asyncio
864    async def test_crash_via_os_exit(self, tmp_path):
865        tool = _make_python_code_tool(
866            tmp_path,
867            "import os\ndef run(x):\n    os._exit(3)\n",
868        )
869        result = await tool.run(None, x="test")
870        assert result.is_error
871        assert "crashed" in result.output
872        assert "exit code" in result.output
@pytest.mark.asyncio
async def test_crash_via_os_exit(self, tmp_path):
863    @pytest.mark.asyncio
864    async def test_crash_via_os_exit(self, tmp_path):
865        tool = _make_python_code_tool(
866            tmp_path,
867            "import os\ndef run(x):\n    os._exit(3)\n",
868        )
869        result = await tool.run(None, x="test")
870        assert result.is_error
871        assert "crashed" in result.output
872        assert "exit code" in result.output
class TestDepthCap:
875class TestDepthCap:
876    @pytest.mark.asyncio
877    async def test_depth_cap_at_10(self, tmp_path):
878        """Depth >= 10 returns an error without spawning."""
879        tool = _make_python_code_tool(tmp_path, 'def run(x):\n    return "ok"\n')
880        token = _depth.set(10)
881        try:
882            result = await tool.run(None, x="test")
883        finally:
884            _depth.reset(token)
885        assert result.is_error
886        assert "maximum nested code execution depth exceeded" in result.output
@pytest.mark.asyncio
async def test_depth_cap_at_10(self, tmp_path):
876    @pytest.mark.asyncio
877    async def test_depth_cap_at_10(self, tmp_path):
878        """Depth >= 10 returns an error without spawning."""
879        tool = _make_python_code_tool(tmp_path, 'def run(x):\n    return "ok"\n')
880        token = _depth.set(10)
881        try:
882            result = await tool.run(None, x="test")
883        finally:
884            _depth.reset(token)
885        assert result.is_error
886        assert "maximum nested code execution depth exceeded" in result.output

Depth >= 10 returns an error without spawning.

class TestSemaphore:
889class TestSemaphore:
890    @pytest.mark.asyncio
891    async def test_semaphore_top_level_only_no_deadlock(self, tmp_path):
892        """Regression: nested code-tool calls bypass the semaphore.
893
894        If nested calls counted against the semaphore, 8 parents each
895        spawning a nested code-tool child would deadlock (parents hold
896        all 8 slots, children wait forever).
897
898        This test sets MAX_CONCURRENCY parents running concurrently,
899        each at depth 1 (simulating nested calls). All should complete
900        without deadlock because nested calls bypass the semaphore.
901        """
902        code = 'def run(x):\n    return "nested_ok"\n'
903        results = []
904
905        async def run_nested(i: int):
906            tool = _make_python_code_tool(tmp_path, code)
907            token = _depth.set(1)
908            try:
909                r = await tool.run(None, x=str(i))
910                results.append(r)
911            finally:
912                _depth.reset(token)
913
914        await asyncio.gather(
915            *(run_nested(i) for i in range(CODE_SANDBOX_MAX_CONCURRENCY))
916        )
917        assert len(results) == CODE_SANDBOX_MAX_CONCURRENCY
918        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):
890    @pytest.mark.asyncio
891    async def test_semaphore_top_level_only_no_deadlock(self, tmp_path):
892        """Regression: nested code-tool calls bypass the semaphore.
893
894        If nested calls counted against the semaphore, 8 parents each
895        spawning a nested code-tool child would deadlock (parents hold
896        all 8 slots, children wait forever).
897
898        This test sets MAX_CONCURRENCY parents running concurrently,
899        each at depth 1 (simulating nested calls). All should complete
900        without deadlock because nested calls bypass the semaphore.
901        """
902        code = 'def run(x):\n    return "nested_ok"\n'
903        results = []
904
905        async def run_nested(i: int):
906            tool = _make_python_code_tool(tmp_path, code)
907            token = _depth.set(1)
908            try:
909                r = await tool.run(None, x=str(i))
910                results.append(r)
911            finally:
912                _depth.reset(token)
913
914        await asyncio.gather(
915            *(run_nested(i) for i in range(CODE_SANDBOX_MAX_CONCURRENCY))
916        )
917        assert len(results) == CODE_SANDBOX_MAX_CONCURRENCY
918        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:
921class TestToolCallRecorder:
922    @pytest.mark.asyncio
923    async def test_recorder_gets_entries(self, tmp_path):
924        fake = FakeTool(
925            "kiln_tool::add_numbers",
926            "fake_add",
927            params=EMPTY_SCHEMA,
928            result=ToolCallResult(output="42"),
929        )
930        log: list[ToolCallLogEntry] = []
931        code = textwrap.dedent("""\
932            from kiln import tools
933            def run(x):
934                r = tools.fake_add()
935                return r
936        """)
937        tool = _make_python_code_tool(
938            tmp_path,
939            code,
940            tool_allowlist=["kiln_tool::add_numbers"],
941            tool_call_recorder=log.append,
942        )
943        with patch(
944            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
945            return_value=fake,
946        ):
947            result = await tool.run(None, x="test")
948        assert not result.is_error
949        assert len(log) == 1
950        assert log[0].tool_name == "fake_add"
951        assert not log[0].is_error
952        assert log[0].output_preview == "42"
953        assert log[0].duration_ms >= 0
@pytest.mark.asyncio
async def test_recorder_gets_entries(self, tmp_path):
922    @pytest.mark.asyncio
923    async def test_recorder_gets_entries(self, tmp_path):
924        fake = FakeTool(
925            "kiln_tool::add_numbers",
926            "fake_add",
927            params=EMPTY_SCHEMA,
928            result=ToolCallResult(output="42"),
929        )
930        log: list[ToolCallLogEntry] = []
931        code = textwrap.dedent("""\
932            from kiln import tools
933            def run(x):
934                r = tools.fake_add()
935                return r
936        """)
937        tool = _make_python_code_tool(
938            tmp_path,
939            code,
940            tool_allowlist=["kiln_tool::add_numbers"],
941            tool_call_recorder=log.append,
942        )
943        with patch(
944            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
945            return_value=fake,
946        ):
947            result = await tool.run(None, x="test")
948        assert not result.is_error
949        assert len(log) == 1
950        assert log[0].tool_name == "fake_add"
951        assert not log[0].is_error
952        assert log[0].output_preview == "42"
953        assert log[0].duration_ms >= 0
class TestAsyncToolsConcurrency:
956class TestAsyncToolsConcurrency:
957    @pytest.mark.asyncio
958    async def test_async_tools_gather_truly_concurrent(self, tmp_path):
959        """async_tools + gather provides real parallelism via to_thread.
960
961        Two fake tools each take ~0.3s. If sequential, wall clock >= 0.6s.
962        With true concurrency via gather + to_thread, wall clock < 0.6s.
963        """
964        slow_fake = FakeTool(
965            "kiln_tool::add_numbers",
966            "fake_add",
967            params=EMPTY_SCHEMA,
968            result=ToolCallResult(output="done"),
969            delay=0.3,
970        )
971        code = textwrap.dedent("""\
972            import asyncio, time
973            from kiln import async_tools
974            async def run(x):
975                start = time.monotonic()
976                a, b = await asyncio.gather(
977                    async_tools.fake_add(),
978                    async_tools.fake_add(),
979                )
980                elapsed = time.monotonic() - start
981                return f"{elapsed:.2f}"
982        """)
983        tool = _make_python_code_tool(
984            tmp_path,
985            code,
986            tool_allowlist=["kiln_tool::add_numbers"],
987        )
988        with patch(
989            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
990            return_value=slow_fake,
991        ):
992            result = await tool.run(None, x="test")
993        assert not result.is_error
994        elapsed = float(result.output)
995        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):
957    @pytest.mark.asyncio
958    async def test_async_tools_gather_truly_concurrent(self, tmp_path):
959        """async_tools + gather provides real parallelism via to_thread.
960
961        Two fake tools each take ~0.3s. If sequential, wall clock >= 0.6s.
962        With true concurrency via gather + to_thread, wall clock < 0.6s.
963        """
964        slow_fake = FakeTool(
965            "kiln_tool::add_numbers",
966            "fake_add",
967            params=EMPTY_SCHEMA,
968            result=ToolCallResult(output="done"),
969            delay=0.3,
970        )
971        code = textwrap.dedent("""\
972            import asyncio, time
973            from kiln import async_tools
974            async def run(x):
975                start = time.monotonic()
976                a, b = await asyncio.gather(
977                    async_tools.fake_add(),
978                    async_tools.fake_add(),
979                )
980                elapsed = time.monotonic() - start
981                return f"{elapsed:.2f}"
982        """)
983        tool = _make_python_code_tool(
984            tmp_path,
985            code,
986            tool_allowlist=["kiln_tool::add_numbers"],
987        )
988        with patch(
989            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
990            return_value=slow_fake,
991        ):
992            result = await tool.run(None, x="test")
993        assert not result.is_error
994        elapsed = float(result.output)
995        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:
1029class TestCallIdRouting:
1030    @pytest.mark.asyncio
1031    async def test_concurrent_calls_routed_to_correct_caller(self, tmp_path):
1032        """4 threads each pass a unique idx kwarg; each gets its own value back.
1033
1034        The echo tool returns the kwargs it received. Each thread asserts it
1035        got back the idx it sent, proving call_id routing maps the right
1036        response to the right waiting caller under concurrency.
1037        """
1038        idx_schema = {
1039            "type": "object",
1040            "properties": {"idx": {"type": "string"}},
1041            "required": ["idx"],
1042        }
1043        code = textwrap.dedent("""\
1044            import json, threading
1045            from kiln import tools
1046            def run(x):
1047                results = [None] * 4
1048                errors = []
1049                def call_tool(i):
1050                    try:
1051                        raw = tools.fake_echo(idx=str(i))
1052                        results[i] = json.loads(raw)["idx"]
1053                    except Exception as e:
1054                        errors.append(f"thread {i}: {e}")
1055                threads = [threading.Thread(target=call_tool, args=(i,)) for i in range(4)]
1056                for t in threads:
1057                    t.start()
1058                for t in threads:
1059                    t.join()
1060                if errors:
1061                    return "errors: " + str(errors)
1062                return ",".join(results)
1063        """)
1064        fake = _EchoFakeTool(
1065            "kiln_tool::add_numbers",
1066            "fake_echo",
1067            params=idx_schema,
1068        )
1069        tool = _make_python_code_tool(
1070            tmp_path,
1071            code,
1072            tool_allowlist=["kiln_tool::add_numbers"],
1073        )
1074        with patch(
1075            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1076            return_value=fake,
1077        ):
1078            result = await tool.run(None, x="test")
1079        assert not result.is_error
1080        parts = result.output.split(",")
1081        assert len(parts) == 4
1082        assert parts == ["0", "1", "2", "3"]
@pytest.mark.asyncio
async def test_concurrent_calls_routed_to_correct_caller(self, tmp_path):
1030    @pytest.mark.asyncio
1031    async def test_concurrent_calls_routed_to_correct_caller(self, tmp_path):
1032        """4 threads each pass a unique idx kwarg; each gets its own value back.
1033
1034        The echo tool returns the kwargs it received. Each thread asserts it
1035        got back the idx it sent, proving call_id routing maps the right
1036        response to the right waiting caller under concurrency.
1037        """
1038        idx_schema = {
1039            "type": "object",
1040            "properties": {"idx": {"type": "string"}},
1041            "required": ["idx"],
1042        }
1043        code = textwrap.dedent("""\
1044            import json, threading
1045            from kiln import tools
1046            def run(x):
1047                results = [None] * 4
1048                errors = []
1049                def call_tool(i):
1050                    try:
1051                        raw = tools.fake_echo(idx=str(i))
1052                        results[i] = json.loads(raw)["idx"]
1053                    except Exception as e:
1054                        errors.append(f"thread {i}: {e}")
1055                threads = [threading.Thread(target=call_tool, args=(i,)) for i in range(4)]
1056                for t in threads:
1057                    t.start()
1058                for t in threads:
1059                    t.join()
1060                if errors:
1061                    return "errors: " + str(errors)
1062                return ",".join(results)
1063        """)
1064        fake = _EchoFakeTool(
1065            "kiln_tool::add_numbers",
1066            "fake_echo",
1067            params=idx_schema,
1068        )
1069        tool = _make_python_code_tool(
1070            tmp_path,
1071            code,
1072            tool_allowlist=["kiln_tool::add_numbers"],
1073        )
1074        with patch(
1075            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1076            return_value=fake,
1077        ):
1078            result = await tool.run(None, x="test")
1079        assert not result.is_error
1080        parts = result.output.split(",")
1081        assert len(parts) == 4
1082        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:
1085class TestUnicodePassthrough:
1086    @pytest.mark.asyncio
1087    async def test_unicode_not_escaped(self, tmp_path):
1088        """ensure_ascii=False: non-ASCII chars pass through un-escaped."""
1089        tool = _make_python_code_tool(
1090            tmp_path,
1091            'def run(x):\n    return {"name": "\\u65e5\\u672c\\u8a9e"}\n',
1092        )
1093        result = await tool.run(None, x="test")
1094        assert not result.is_error
1095        parsed = json.loads(result.output)
1096        assert parsed["name"] == "日本語"
1097        assert "\\u" not in result.output
@pytest.mark.asyncio
async def test_unicode_not_escaped(self, tmp_path):
1086    @pytest.mark.asyncio
1087    async def test_unicode_not_escaped(self, tmp_path):
1088        """ensure_ascii=False: non-ASCII chars pass through un-escaped."""
1089        tool = _make_python_code_tool(
1090            tmp_path,
1091            'def run(x):\n    return {"name": "\\u65e5\\u672c\\u8a9e"}\n',
1092        )
1093        result = await tool.run(None, x="test")
1094        assert not result.is_error
1095        parsed = json.loads(result.output)
1096        assert parsed["name"] == "日本語"
1097        assert "\\u" not in result.output

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

class TestSpawnLockIdentity:
1100class TestSpawnLockIdentity:
1101    def test_spawn_lock_shared(self):
1102        """Code tools and code evals spawn through the same bridge / _spawn_lock."""
1103        from kiln_ai.tools import sandbox_bridge
1104
1105        assert (
1106            sandbox_bridge.start_process_with_light_main.__module__
1107            == "kiln_ai.sandbox.spawn"
1108        )
1109        from kiln_ai.sandbox.spawn import _spawn_lock as shared_lock
1110
1111        assert shared_lock is _spawn_lock
def test_spawn_lock_shared(self):
1101    def test_spawn_lock_shared(self):
1102        """Code tools and code evals spawn through the same bridge / _spawn_lock."""
1103        from kiln_ai.tools import sandbox_bridge
1104
1105        assert (
1106            sandbox_bridge.start_process_with_light_main.__module__
1107            == "kiln_ai.sandbox.spawn"
1108        )
1109        from kiln_ai.sandbox.spawn import _spawn_lock as shared_lock
1110
1111        assert shared_lock is _spawn_lock

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

class TestRealBuiltInTools:
1119class TestRealBuiltInTools:
1120    """Tests that exercise real built-in tool dispatch without mocking
1121    ``tool_from_id_and_project``, ensuring the name-derivation path is
1122    exercised end-to-end.
1123    """
1124
1125    @pytest.mark.asyncio
1126    async def test_keyword_call_by_canonical_name_succeeds(self, tmp_path):
1127        """tools.add(a=1, b=2) returns '3' — the canonical name from list_tools."""
1128        code = textwrap.dedent("""\
1129            from kiln import tools
1130            def run(x):
1131                return tools.add(a=1, b=2)
1132        """)
1133        tool = _make_python_code_tool(
1134            tmp_path,
1135            code,
1136            tool_allowlist=["kiln_tool::add_numbers"],
1137        )
1138        result = await tool.run(None, x="test")
1139        assert not result.is_error, f"Expected success, got: {result.output}"
1140        assert result.output == "3"
1141
1142    @pytest.mark.asyncio
1143    async def test_list_tools_driven_call_succeeds(self, tmp_path):
1144        """Call using the name returned by list_tools() succeeds."""
1145        code = textwrap.dedent("""\
1146            from kiln import tools
1147            def run(x):
1148                tl = tools.list_tools()
1149                fn_name = tl[0]["name"]
1150                result = getattr(tools, fn_name)(a=5, b=3)
1151                return fn_name + ":" + result
1152        """)
1153        tool = _make_python_code_tool(
1154            tmp_path,
1155            code,
1156            tool_allowlist=["kiln_tool::add_numbers"],
1157        )
1158        result = await tool.run(None, x="test")
1159        assert not result.is_error, f"Expected success, got: {result.output}"
1160        assert result.output == "add:8"
1161
1162    @pytest.mark.asyncio
1163    async def test_friendly_name_not_allowed(self, tmp_path):
1164        """tools.Addition(a=1,b=2) raises ToolNotAllowed listing canonical names."""
1165        code = textwrap.dedent("""\
1166            from kiln.tools import ToolNotAllowed
1167            from kiln import tools
1168            def run(x):
1169                try:
1170                    tools.Addition(a=1, b=2)
1171                except ToolNotAllowed as e:
1172                    return e.message
1173                return "no error"
1174        """)
1175        tool = _make_python_code_tool(
1176            tmp_path,
1177            code,
1178            tool_allowlist=["kiln_tool::add_numbers"],
1179        )
1180        result = await tool.run(None, x="test")
1181        assert not result.is_error
1182        assert "not available" in result.output
1183        assert "'add'" in result.output
1184
1185    @pytest.mark.asyncio
1186    async def test_nonsense_name_not_allowed(self, tmp_path):
1187        """tools.bad_tool() raises ToolNotAllowed listing available names."""
1188        code = textwrap.dedent("""\
1189            from kiln.tools import ToolNotAllowed
1190            from kiln import tools
1191            def run(x):
1192                try:
1193                    tools.bad_tool(a=1)
1194                except ToolNotAllowed as e:
1195                    return e.message
1196                return "no error"
1197        """)
1198        tool = _make_python_code_tool(
1199            tmp_path,
1200            code,
1201            tool_allowlist=["kiln_tool::add_numbers"],
1202        )
1203        result = await tool.run(None, x="test")
1204        assert not result.is_error
1205        assert "not available" in result.output
1206        assert "'add'" in result.output
1207
1208    @pytest.mark.asyncio
1209    async def test_positional_args_error_message(self, tmp_path):
1210        """tools.add(1, 2) raises ToolCallError mentioning keyword args and params."""
1211        code = textwrap.dedent("""\
1212            from kiln.tools import ToolCallError
1213            from kiln import tools
1214            def run(x):
1215                try:
1216                    tools.add(1, 2)
1217                except ToolCallError as e:
1218                    return e.message
1219                return "no error"
1220        """)
1221        tool = _make_python_code_tool(
1222            tmp_path,
1223            code,
1224            tool_allowlist=["kiln_tool::add_numbers"],
1225        )
1226        result = await tool.run(None, x="test")
1227        assert not result.is_error
1228        assert "keyword arguments" in result.output
1229        assert "tools.add(" in result.output
1230        assert "a: number (required)" in result.output
1231        assert "b: number (required)" in result.output
1232
1233    @pytest.mark.asyncio
1234    async def test_wrong_kwargs_error_shows_schema(self, tmp_path):
1235        """tools.add(x=1) raises ToolCallError showing expected parameters."""
1236        code = textwrap.dedent("""\
1237            from kiln.tools import ToolCallError
1238            from kiln import tools
1239            def run(x):
1240                try:
1241                    tools.add(x=1)
1242                except ToolCallError as e:
1243                    return e.message
1244                return "no error"
1245        """)
1246        tool = _make_python_code_tool(
1247            tmp_path,
1248            code,
1249            tool_allowlist=["kiln_tool::add_numbers"],
1250        )
1251        result = await tool.run(None, x="test")
1252        assert not result.is_error
1253        assert "Expected parameters:" in result.output
1254        assert "a: number (required)" in result.output
1255
1256    @pytest.mark.asyncio
1257    async def test_name_consistency_across_all_builtins(self, tmp_path):
1258        """For every KilnBuiltInToolId the dispatch-map name matches tool.name()
1259        AND matches what list_tools reports."""
1260        from kiln_ai.datamodel.tool_id import KilnBuiltInToolId
1261        from kiln_ai.tools.tool_registry import tool_from_id_and_project
1262
1263        project = _make_project(tmp_path)
1264
1265        math_ids = [
1266            KilnBuiltInToolId.ADD_NUMBERS,
1267            KilnBuiltInToolId.SUBTRACT_NUMBERS,
1268            KilnBuiltInToolId.MULTIPLY_NUMBERS,
1269            KilnBuiltInToolId.DIVIDE_NUMBERS,
1270        ]
1271
1272        for builtin_id in math_ids:
1273            tool_id = builtin_id.value
1274            real_tool = tool_from_id_and_project(tool_id, project=project)
1275            real_name = await real_tool.name()
1276
1277            ct = _make_code_tool(
1278                'def run(x): return "ok"',
1279                tool_allowlist=[tool_id],
1280            )
1281            ct.parent = project
1282            server = NestedToolServer(
1283                allowlist=ct.tool_allowlist, project=project, task=None, context=None
1284            )
1285            dispatch_names = list((await server.name_map()).keys())
1286
1287            assert dispatch_names == [real_name], (
1288                f"For {builtin_id}: dispatch name {dispatch_names} != "
1289                f"tool.name() '{real_name}'"
1290            )
1291
1292    @pytest.mark.asyncio
1293    async def test_async_proxy_keyword_call(self, tmp_path):
1294        """async_tools.subtract(a=5, b=3) returns '2'."""
1295        code = textwrap.dedent("""\
1296            from kiln import async_tools
1297            async def run(x):
1298                return await async_tools.subtract(a=5, b=3)
1299        """)
1300        tool = _make_python_code_tool(
1301            tmp_path,
1302            code,
1303            tool_allowlist=["kiln_tool::subtract_numbers"],
1304        )
1305        result = await tool.run(None, x="test")
1306        assert not result.is_error, f"Expected success, got: {result.output}"
1307        assert result.output == "2"
1308
1309    @pytest.mark.asyncio
1310    async def test_async_proxy_positional_error(self, tmp_path):
1311        """async_tools.add(1, 2) raises ToolCallError with a helpful message."""
1312        code = textwrap.dedent("""\
1313            from kiln.tools import ToolCallError
1314            from kiln import async_tools
1315            async def run(x):
1316                try:
1317                    await async_tools.add(1, 2)
1318                except ToolCallError as e:
1319                    return e.message
1320                return "no error"
1321        """)
1322        tool = _make_python_code_tool(
1323            tmp_path,
1324            code,
1325            tool_allowlist=["kiln_tool::add_numbers"],
1326        )
1327        result = await tool.run(None, x="test")
1328        assert not result.is_error
1329        assert "keyword arguments" in result.output
1330
1331    @pytest.mark.asyncio
1332    async def test_positional_on_nonsense_name_still_not_allowed(self, tmp_path):
1333        """tools.bad_tool(1) raises ToolNotAllowed (not TypeError), regardless of args."""
1334        code = textwrap.dedent("""\
1335            from kiln.tools import ToolNotAllowed
1336            from kiln import tools
1337            def run(x):
1338                try:
1339                    tools.bad_tool(1, 2)
1340                except ToolNotAllowed as e:
1341                    return e.message
1342                return "no error"
1343        """)
1344        tool = _make_python_code_tool(
1345            tmp_path,
1346            code,
1347            tool_allowlist=["kiln_tool::add_numbers"],
1348        )
1349        result = await tool.run(None, x="test")
1350        assert not result.is_error
1351        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):
1125    @pytest.mark.asyncio
1126    async def test_keyword_call_by_canonical_name_succeeds(self, tmp_path):
1127        """tools.add(a=1, b=2) returns '3' — the canonical name from list_tools."""
1128        code = textwrap.dedent("""\
1129            from kiln import tools
1130            def run(x):
1131                return tools.add(a=1, b=2)
1132        """)
1133        tool = _make_python_code_tool(
1134            tmp_path,
1135            code,
1136            tool_allowlist=["kiln_tool::add_numbers"],
1137        )
1138        result = await tool.run(None, x="test")
1139        assert not result.is_error, f"Expected success, got: {result.output}"
1140        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):
1142    @pytest.mark.asyncio
1143    async def test_list_tools_driven_call_succeeds(self, tmp_path):
1144        """Call using the name returned by list_tools() succeeds."""
1145        code = textwrap.dedent("""\
1146            from kiln import tools
1147            def run(x):
1148                tl = tools.list_tools()
1149                fn_name = tl[0]["name"]
1150                result = getattr(tools, fn_name)(a=5, b=3)
1151                return fn_name + ":" + result
1152        """)
1153        tool = _make_python_code_tool(
1154            tmp_path,
1155            code,
1156            tool_allowlist=["kiln_tool::add_numbers"],
1157        )
1158        result = await tool.run(None, x="test")
1159        assert not result.is_error, f"Expected success, got: {result.output}"
1160        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):
1162    @pytest.mark.asyncio
1163    async def test_friendly_name_not_allowed(self, tmp_path):
1164        """tools.Addition(a=1,b=2) raises ToolNotAllowed listing canonical names."""
1165        code = textwrap.dedent("""\
1166            from kiln.tools import ToolNotAllowed
1167            from kiln import tools
1168            def run(x):
1169                try:
1170                    tools.Addition(a=1, b=2)
1171                except ToolNotAllowed as e:
1172                    return e.message
1173                return "no error"
1174        """)
1175        tool = _make_python_code_tool(
1176            tmp_path,
1177            code,
1178            tool_allowlist=["kiln_tool::add_numbers"],
1179        )
1180        result = await tool.run(None, x="test")
1181        assert not result.is_error
1182        assert "not available" in result.output
1183        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):
1185    @pytest.mark.asyncio
1186    async def test_nonsense_name_not_allowed(self, tmp_path):
1187        """tools.bad_tool() raises ToolNotAllowed listing available names."""
1188        code = textwrap.dedent("""\
1189            from kiln.tools import ToolNotAllowed
1190            from kiln import tools
1191            def run(x):
1192                try:
1193                    tools.bad_tool(a=1)
1194                except ToolNotAllowed as e:
1195                    return e.message
1196                return "no error"
1197        """)
1198        tool = _make_python_code_tool(
1199            tmp_path,
1200            code,
1201            tool_allowlist=["kiln_tool::add_numbers"],
1202        )
1203        result = await tool.run(None, x="test")
1204        assert not result.is_error
1205        assert "not available" in result.output
1206        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):
1208    @pytest.mark.asyncio
1209    async def test_positional_args_error_message(self, tmp_path):
1210        """tools.add(1, 2) raises ToolCallError mentioning keyword args and params."""
1211        code = textwrap.dedent("""\
1212            from kiln.tools import ToolCallError
1213            from kiln import tools
1214            def run(x):
1215                try:
1216                    tools.add(1, 2)
1217                except ToolCallError as e:
1218                    return e.message
1219                return "no error"
1220        """)
1221        tool = _make_python_code_tool(
1222            tmp_path,
1223            code,
1224            tool_allowlist=["kiln_tool::add_numbers"],
1225        )
1226        result = await tool.run(None, x="test")
1227        assert not result.is_error
1228        assert "keyword arguments" in result.output
1229        assert "tools.add(" in result.output
1230        assert "a: number (required)" in result.output
1231        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):
1233    @pytest.mark.asyncio
1234    async def test_wrong_kwargs_error_shows_schema(self, tmp_path):
1235        """tools.add(x=1) raises ToolCallError showing expected parameters."""
1236        code = textwrap.dedent("""\
1237            from kiln.tools import ToolCallError
1238            from kiln import tools
1239            def run(x):
1240                try:
1241                    tools.add(x=1)
1242                except ToolCallError as e:
1243                    return e.message
1244                return "no error"
1245        """)
1246        tool = _make_python_code_tool(
1247            tmp_path,
1248            code,
1249            tool_allowlist=["kiln_tool::add_numbers"],
1250        )
1251        result = await tool.run(None, x="test")
1252        assert not result.is_error
1253        assert "Expected parameters:" in result.output
1254        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):
1256    @pytest.mark.asyncio
1257    async def test_name_consistency_across_all_builtins(self, tmp_path):
1258        """For every KilnBuiltInToolId the dispatch-map name matches tool.name()
1259        AND matches what list_tools reports."""
1260        from kiln_ai.datamodel.tool_id import KilnBuiltInToolId
1261        from kiln_ai.tools.tool_registry import tool_from_id_and_project
1262
1263        project = _make_project(tmp_path)
1264
1265        math_ids = [
1266            KilnBuiltInToolId.ADD_NUMBERS,
1267            KilnBuiltInToolId.SUBTRACT_NUMBERS,
1268            KilnBuiltInToolId.MULTIPLY_NUMBERS,
1269            KilnBuiltInToolId.DIVIDE_NUMBERS,
1270        ]
1271
1272        for builtin_id in math_ids:
1273            tool_id = builtin_id.value
1274            real_tool = tool_from_id_and_project(tool_id, project=project)
1275            real_name = await real_tool.name()
1276
1277            ct = _make_code_tool(
1278                'def run(x): return "ok"',
1279                tool_allowlist=[tool_id],
1280            )
1281            ct.parent = project
1282            server = NestedToolServer(
1283                allowlist=ct.tool_allowlist, project=project, task=None, context=None
1284            )
1285            dispatch_names = list((await server.name_map()).keys())
1286
1287            assert dispatch_names == [real_name], (
1288                f"For {builtin_id}: dispatch name {dispatch_names} != "
1289                f"tool.name() '{real_name}'"
1290            )

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):
1292    @pytest.mark.asyncio
1293    async def test_async_proxy_keyword_call(self, tmp_path):
1294        """async_tools.subtract(a=5, b=3) returns '2'."""
1295        code = textwrap.dedent("""\
1296            from kiln import async_tools
1297            async def run(x):
1298                return await async_tools.subtract(a=5, b=3)
1299        """)
1300        tool = _make_python_code_tool(
1301            tmp_path,
1302            code,
1303            tool_allowlist=["kiln_tool::subtract_numbers"],
1304        )
1305        result = await tool.run(None, x="test")
1306        assert not result.is_error, f"Expected success, got: {result.output}"
1307        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):
1309    @pytest.mark.asyncio
1310    async def test_async_proxy_positional_error(self, tmp_path):
1311        """async_tools.add(1, 2) raises ToolCallError with a helpful message."""
1312        code = textwrap.dedent("""\
1313            from kiln.tools import ToolCallError
1314            from kiln import async_tools
1315            async def run(x):
1316                try:
1317                    await async_tools.add(1, 2)
1318                except ToolCallError as e:
1319                    return e.message
1320                return "no error"
1321        """)
1322        tool = _make_python_code_tool(
1323            tmp_path,
1324            code,
1325            tool_allowlist=["kiln_tool::add_numbers"],
1326        )
1327        result = await tool.run(None, x="test")
1328        assert not result.is_error
1329        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):
1331    @pytest.mark.asyncio
1332    async def test_positional_on_nonsense_name_still_not_allowed(self, tmp_path):
1333        """tools.bad_tool(1) raises ToolNotAllowed (not TypeError), regardless of args."""
1334        code = textwrap.dedent("""\
1335            from kiln.tools import ToolNotAllowed
1336            from kiln import tools
1337            def run(x):
1338                try:
1339                    tools.bad_tool(1, 2)
1340                except ToolNotAllowed as e:
1341                    return e.message
1342                return "no error"
1343        """)
1344        tool = _make_python_code_tool(
1345            tmp_path,
1346            code,
1347            tool_allowlist=["kiln_tool::add_numbers"],
1348        )
1349        result = await tool.run(None, x="test")
1350        assert not result.is_error
1351        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:
1428class TestUIExampleParallelWithRetries:
1429    """Validate the 'Parallel with Retries' example from the Code Tool Examples modal."""
1430
1431    @pytest.mark.asyncio
1432    async def test_parallel_with_retries_happy_path(self, tmp_path):
1433        fetch_url_responses = {
1434            "https://a.com": '{"status": "ok_a"}',
1435            "https://b.com": '{"status": "ok_b"}',
1436        }
1437        fake = FakeTool(
1438            "mcp::remote::test_server::fetch_url",
1439            "fetch_url",
1440            fn_desc="Fetch a URL",
1441            params={
1442                "type": "object",
1443                "properties": {"url": {"type": "string"}},
1444                "required": ["url"],
1445            },
1446        )
1447
1448        async def route_fetch(context=None, **kwargs):
1449            url = kwargs["url"]
1450            return ToolCallResult(output=fetch_url_responses[url])
1451
1452        fake.run = route_fetch  # type: ignore[assignment]
1453
1454        tool = _make_python_code_tool(
1455            tmp_path,
1456            EXAMPLE_PARALLEL_WITH_RETRIES,
1457            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1458            parameters_schema={
1459                "type": "object",
1460                "properties": {
1461                    "urls": {"type": "array", "items": {"type": "string"}},
1462                    "max_retries": {"type": "integer"},
1463                },
1464                "required": ["urls"],
1465            },
1466        )
1467        with patch(
1468            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1469            return_value=fake,
1470        ):
1471            result = await tool.run(
1472                None, urls=["https://a.com", "https://b.com"], max_retries=1
1473            )
1474        assert not result.is_error, f"Expected success, got: {result.output}"
1475        parsed = json.loads(result.output)
1476        assert parsed["https://a.com"] == {"status": "ok_a"}
1477        assert parsed["https://b.com"] == {"status": "ok_b"}
1478
1479    @pytest.mark.asyncio
1480    async def test_parallel_with_retries_error_fallback(self, tmp_path):
1481        """When a tool call fails, the retry logic catches the exception and
1482        returns an error dict after exhausting retries."""
1483        fake = FakeTool(
1484            "mcp::remote::test_server::fetch_url",
1485            "fetch_url",
1486            fn_desc="Fetch a URL",
1487            params={
1488                "type": "object",
1489                "properties": {"url": {"type": "string"}},
1490                "required": ["url"],
1491            },
1492            result=ToolCallResult(
1493                output="connection refused",
1494                is_error=True,
1495                error_message="connection refused",
1496            ),
1497        )
1498        tool = _make_python_code_tool(
1499            tmp_path,
1500            EXAMPLE_PARALLEL_WITH_RETRIES,
1501            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1502            parameters_schema={
1503                "type": "object",
1504                "properties": {
1505                    "urls": {"type": "array", "items": {"type": "string"}},
1506                    "max_retries": {"type": "integer"},
1507                },
1508                "required": ["urls"],
1509            },
1510        )
1511        with patch(
1512            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1513            return_value=fake,
1514        ):
1515            result = await tool.run(None, urls=["https://fail.com"], max_retries=1)
1516        assert not result.is_error, f"Expected success, got: {result.output}"
1517        parsed = json.loads(result.output)
1518        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):
1431    @pytest.mark.asyncio
1432    async def test_parallel_with_retries_happy_path(self, tmp_path):
1433        fetch_url_responses = {
1434            "https://a.com": '{"status": "ok_a"}',
1435            "https://b.com": '{"status": "ok_b"}',
1436        }
1437        fake = FakeTool(
1438            "mcp::remote::test_server::fetch_url",
1439            "fetch_url",
1440            fn_desc="Fetch a URL",
1441            params={
1442                "type": "object",
1443                "properties": {"url": {"type": "string"}},
1444                "required": ["url"],
1445            },
1446        )
1447
1448        async def route_fetch(context=None, **kwargs):
1449            url = kwargs["url"]
1450            return ToolCallResult(output=fetch_url_responses[url])
1451
1452        fake.run = route_fetch  # type: ignore[assignment]
1453
1454        tool = _make_python_code_tool(
1455            tmp_path,
1456            EXAMPLE_PARALLEL_WITH_RETRIES,
1457            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1458            parameters_schema={
1459                "type": "object",
1460                "properties": {
1461                    "urls": {"type": "array", "items": {"type": "string"}},
1462                    "max_retries": {"type": "integer"},
1463                },
1464                "required": ["urls"],
1465            },
1466        )
1467        with patch(
1468            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1469            return_value=fake,
1470        ):
1471            result = await tool.run(
1472                None, urls=["https://a.com", "https://b.com"], max_retries=1
1473            )
1474        assert not result.is_error, f"Expected success, got: {result.output}"
1475        parsed = json.loads(result.output)
1476        assert parsed["https://a.com"] == {"status": "ok_a"}
1477        assert parsed["https://b.com"] == {"status": "ok_b"}
@pytest.mark.asyncio
async def test_parallel_with_retries_error_fallback(self, tmp_path):
1479    @pytest.mark.asyncio
1480    async def test_parallel_with_retries_error_fallback(self, tmp_path):
1481        """When a tool call fails, the retry logic catches the exception and
1482        returns an error dict after exhausting retries."""
1483        fake = FakeTool(
1484            "mcp::remote::test_server::fetch_url",
1485            "fetch_url",
1486            fn_desc="Fetch a URL",
1487            params={
1488                "type": "object",
1489                "properties": {"url": {"type": "string"}},
1490                "required": ["url"],
1491            },
1492            result=ToolCallResult(
1493                output="connection refused",
1494                is_error=True,
1495                error_message="connection refused",
1496            ),
1497        )
1498        tool = _make_python_code_tool(
1499            tmp_path,
1500            EXAMPLE_PARALLEL_WITH_RETRIES,
1501            tool_allowlist=["mcp::remote::test_server::fetch_url"],
1502            parameters_schema={
1503                "type": "object",
1504                "properties": {
1505                    "urls": {"type": "array", "items": {"type": "string"}},
1506                    "max_retries": {"type": "integer"},
1507                },
1508                "required": ["urls"],
1509            },
1510        )
1511        with patch(
1512            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1513            return_value=fake,
1514        ):
1515            result = await tool.run(None, urls=["https://fail.com"], max_retries=1)
1516        assert not result.is_error, f"Expected success, got: {result.output}"
1517        parsed = json.loads(result.output)
1518        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:
1521class TestUIExampleAsyncFanOut:
1522    """Validate the 'Async Fan-Out' example from the Code Tool Examples modal."""
1523
1524    @pytest.mark.asyncio
1525    async def test_async_fan_out_happy_path(self, tmp_path):
1526        user_data = {
1527            "u1": '{"name": "Alice", "id": "u1"}',
1528            "u2": '{"name": "Bob", "id": "u2"}',
1529        }
1530        fake = FakeTool(
1531            "mcp::remote::test_server::get_user",
1532            "get_user",
1533            fn_desc="Get user details",
1534            params={
1535                "type": "object",
1536                "properties": {"id": {"type": "string"}},
1537                "required": ["id"],
1538            },
1539        )
1540
1541        async def route_user(context=None, **kwargs):
1542            uid = kwargs["id"]
1543            return ToolCallResult(output=user_data[uid])
1544
1545        fake.run = route_user  # type: ignore[assignment]
1546
1547        tool = _make_python_code_tool(
1548            tmp_path,
1549            EXAMPLE_ASYNC_FAN_OUT,
1550            tool_allowlist=["mcp::remote::test_server::get_user"],
1551            parameters_schema={
1552                "type": "object",
1553                "properties": {
1554                    "user_ids": {"type": "array", "items": {"type": "string"}},
1555                },
1556                "required": ["user_ids"],
1557            },
1558        )
1559        with patch(
1560            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1561            return_value=fake,
1562        ):
1563            result = await tool.run(None, user_ids=["u1", "u2"])
1564        assert not result.is_error, f"Expected success, got: {result.output}"
1565        parsed = json.loads(result.output)
1566        assert len(parsed) == 2
1567        assert parsed[0] == {"name": "Alice", "id": "u1"}
1568        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):
1524    @pytest.mark.asyncio
1525    async def test_async_fan_out_happy_path(self, tmp_path):
1526        user_data = {
1527            "u1": '{"name": "Alice", "id": "u1"}',
1528            "u2": '{"name": "Bob", "id": "u2"}',
1529        }
1530        fake = FakeTool(
1531            "mcp::remote::test_server::get_user",
1532            "get_user",
1533            fn_desc="Get user details",
1534            params={
1535                "type": "object",
1536                "properties": {"id": {"type": "string"}},
1537                "required": ["id"],
1538            },
1539        )
1540
1541        async def route_user(context=None, **kwargs):
1542            uid = kwargs["id"]
1543            return ToolCallResult(output=user_data[uid])
1544
1545        fake.run = route_user  # type: ignore[assignment]
1546
1547        tool = _make_python_code_tool(
1548            tmp_path,
1549            EXAMPLE_ASYNC_FAN_OUT,
1550            tool_allowlist=["mcp::remote::test_server::get_user"],
1551            parameters_schema={
1552                "type": "object",
1553                "properties": {
1554                    "user_ids": {"type": "array", "items": {"type": "string"}},
1555                },
1556                "required": ["user_ids"],
1557            },
1558        )
1559        with patch(
1560            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1561            return_value=fake,
1562        ):
1563            result = await tool.run(None, user_ids=["u1", "u2"])
1564        assert not result.is_error, f"Expected success, got: {result.output}"
1565        parsed = json.loads(result.output)
1566        assert len(parsed) == 2
1567        assert parsed[0] == {"name": "Alice", "id": "u1"}
1568        assert parsed[1] == {"name": "Bob", "id": "u2"}
class TestUIExampleFilterAndTransform:
1571class TestUIExampleFilterAndTransform:
1572    """Validate the 'Filter & Transform' example from the Code Tool Examples modal."""
1573
1574    @pytest.mark.asyncio
1575    async def test_filter_and_transform_happy_path(self, tmp_path):
1576        search_results = json.dumps(
1577            [
1578                {"title": "Result 1", "url": "https://1.com", "score": 0.9},
1579                {"title": "Result 2", "url": "https://2.com", "score": 0.8},
1580                {"description": "no title or url"},
1581                {"title": "Result 3", "url": "https://3.com", "score": 0.7},
1582            ]
1583        )
1584        fake = FakeTool(
1585            "mcp::remote::test_server::search",
1586            "search",
1587            fn_desc="Search",
1588            params={
1589                "type": "object",
1590                "properties": {"query": {"type": "string"}},
1591                "required": ["query"],
1592            },
1593            result=ToolCallResult(output=search_results),
1594        )
1595        tool = _make_python_code_tool(
1596            tmp_path,
1597            EXAMPLE_FILTER_AND_TRANSFORM,
1598            tool_allowlist=["mcp::remote::test_server::search"],
1599            parameters_schema={
1600                "type": "object",
1601                "properties": {
1602                    "query": {"type": "string"},
1603                    "max_results": {"type": "integer"},
1604                },
1605                "required": ["query"],
1606            },
1607        )
1608        with patch(
1609            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1610            return_value=fake,
1611        ):
1612            result = await tool.run(None, query="test query")
1613        assert not result.is_error, f"Expected success, got: {result.output}"
1614        parsed = json.loads(result.output)
1615        assert len(parsed) == 3
1616        assert parsed[0] == {"title": "Result 1", "url": "https://1.com"}
1617        assert parsed[1] == {"title": "Result 2", "url": "https://2.com"}
1618        assert parsed[2] == {"title": "Result 3", "url": "https://3.com"}
1619
1620    @pytest.mark.asyncio
1621    async def test_filter_and_transform_respects_max_results(self, tmp_path):
1622        search_results = json.dumps(
1623            [{"title": f"R{i}", "url": f"https://{i}.com"} for i in range(20)]
1624        )
1625        fake = FakeTool(
1626            "mcp::remote::test_server::search",
1627            "search",
1628            fn_desc="Search",
1629            params={
1630                "type": "object",
1631                "properties": {"query": {"type": "string"}},
1632                "required": ["query"],
1633            },
1634            result=ToolCallResult(output=search_results),
1635        )
1636        tool = _make_python_code_tool(
1637            tmp_path,
1638            EXAMPLE_FILTER_AND_TRANSFORM,
1639            tool_allowlist=["mcp::remote::test_server::search"],
1640            parameters_schema={
1641                "type": "object",
1642                "properties": {
1643                    "query": {"type": "string"},
1644                    "max_results": {"type": "integer"},
1645                },
1646                "required": ["query"],
1647            },
1648        )
1649        with patch(
1650            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1651            return_value=fake,
1652        ):
1653            result = await tool.run(None, query="test", max_results=3)
1654        assert not result.is_error, f"Expected success, got: {result.output}"
1655        parsed = json.loads(result.output)
1656        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):
1574    @pytest.mark.asyncio
1575    async def test_filter_and_transform_happy_path(self, tmp_path):
1576        search_results = json.dumps(
1577            [
1578                {"title": "Result 1", "url": "https://1.com", "score": 0.9},
1579                {"title": "Result 2", "url": "https://2.com", "score": 0.8},
1580                {"description": "no title or url"},
1581                {"title": "Result 3", "url": "https://3.com", "score": 0.7},
1582            ]
1583        )
1584        fake = FakeTool(
1585            "mcp::remote::test_server::search",
1586            "search",
1587            fn_desc="Search",
1588            params={
1589                "type": "object",
1590                "properties": {"query": {"type": "string"}},
1591                "required": ["query"],
1592            },
1593            result=ToolCallResult(output=search_results),
1594        )
1595        tool = _make_python_code_tool(
1596            tmp_path,
1597            EXAMPLE_FILTER_AND_TRANSFORM,
1598            tool_allowlist=["mcp::remote::test_server::search"],
1599            parameters_schema={
1600                "type": "object",
1601                "properties": {
1602                    "query": {"type": "string"},
1603                    "max_results": {"type": "integer"},
1604                },
1605                "required": ["query"],
1606            },
1607        )
1608        with patch(
1609            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1610            return_value=fake,
1611        ):
1612            result = await tool.run(None, query="test query")
1613        assert not result.is_error, f"Expected success, got: {result.output}"
1614        parsed = json.loads(result.output)
1615        assert len(parsed) == 3
1616        assert parsed[0] == {"title": "Result 1", "url": "https://1.com"}
1617        assert parsed[1] == {"title": "Result 2", "url": "https://2.com"}
1618        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):
1620    @pytest.mark.asyncio
1621    async def test_filter_and_transform_respects_max_results(self, tmp_path):
1622        search_results = json.dumps(
1623            [{"title": f"R{i}", "url": f"https://{i}.com"} for i in range(20)]
1624        )
1625        fake = FakeTool(
1626            "mcp::remote::test_server::search",
1627            "search",
1628            fn_desc="Search",
1629            params={
1630                "type": "object",
1631                "properties": {"query": {"type": "string"}},
1632                "required": ["query"],
1633            },
1634            result=ToolCallResult(output=search_results),
1635        )
1636        tool = _make_python_code_tool(
1637            tmp_path,
1638            EXAMPLE_FILTER_AND_TRANSFORM,
1639            tool_allowlist=["mcp::remote::test_server::search"],
1640            parameters_schema={
1641                "type": "object",
1642                "properties": {
1643                    "query": {"type": "string"},
1644                    "max_results": {"type": "integer"},
1645                },
1646                "required": ["query"],
1647            },
1648        )
1649        with patch(
1650            "kiln_ai.tools.tool_registry.tool_from_id_and_project",
1651            return_value=fake,
1652        ):
1653            result = await tool.run(None, query="test", max_results=3)
1654        assert not result.is_error, f"Expected success, got: {result.output}"
1655        parsed = json.loads(result.output)
1656        assert len(parsed) == 3