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