test_searchable_toolset.py
1 # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 2 # 3 # SPDX-License-Identifier: Apache-2.0 4 5 6 import os 7 8 import pytest 9 10 from haystack.tools import SearchableToolset, Tool, Toolset 11 from haystack.tools.from_function import create_tool_from_function 12 13 14 # Test helper functions 15 def get_weather(city: str) -> str: 16 """Get current weather for a city.""" 17 return f"Weather in {city}: 22°C, sunny" 18 19 20 def add_numbers(a: int, b: int) -> int: 21 """Add two numbers together.""" 22 return a + b 23 24 25 def multiply_numbers(a: int, b: int) -> int: 26 """Multiply two numbers.""" 27 return a * b 28 29 30 def get_stock_price(symbol: str) -> str: 31 """Get stock price by ticker symbol.""" 32 return f"{symbol}: $150.00" 33 34 35 def search_database(query: str) -> str: 36 """Search the database for records.""" 37 return f"Found 5 records matching '{query}'" 38 39 40 def send_email(to: str, subject: str, body: str) -> str: 41 """Send an email to a recipient.""" 42 return f"Email sent to {to}" 43 44 45 def calculate_tax(amount: float, rate: float) -> float: 46 """Calculate tax on an amount.""" 47 return amount * rate 48 49 50 def convert_currency(amount: float, from_currency: str, to_currency: str) -> float: 51 """Convert currency from one to another.""" 52 return amount * 1.1 # Simplified conversion 53 54 55 # Test fixtures 56 @pytest.fixture 57 def weather_tool(): 58 return create_tool_from_function(get_weather) 59 60 61 @pytest.fixture 62 def add_tool(): 63 return create_tool_from_function(add_numbers) 64 65 66 @pytest.fixture 67 def multiply_tool(): 68 return create_tool_from_function(multiply_numbers) 69 70 71 @pytest.fixture 72 def stock_tool(): 73 return create_tool_from_function(get_stock_price) 74 75 76 @pytest.fixture 77 def small_catalog(weather_tool, add_tool, multiply_tool): 78 """Small catalog that triggers passthrough mode (< 8 tools).""" 79 return [weather_tool, add_tool, multiply_tool] 80 81 82 @pytest.fixture 83 def large_catalog(): 84 """Larger catalog that requires discovery (>= 8 tools).""" 85 return [ 86 create_tool_from_function(fn) 87 for fn in [ 88 get_weather, 89 add_numbers, 90 multiply_numbers, 91 get_stock_price, 92 search_database, 93 send_email, 94 calculate_tax, 95 convert_currency, 96 ] 97 ] 98 99 100 class TestSearchableToolset: 101 def test_init_with_invalid_catalog(self): 102 with pytest.raises(TypeError): 103 SearchableToolset(catalog=123) 104 with pytest.raises(TypeError): 105 SearchableToolset(catalog=[123]) 106 with pytest.raises(TypeError): 107 SearchableToolset( 108 catalog=Tool( 109 name="test", 110 description="test", 111 parameters={"type": "object", "properties": {}}, 112 function=lambda: None, 113 ) 114 ) 115 116 def test_not_implemented_methods(self): 117 toolset = SearchableToolset(catalog=[]) 118 with pytest.raises(NotImplementedError): 119 toolset + Tool( 120 name="test", description="test", parameters={"type": "object", "properties": {}}, function=lambda: None 121 ) 122 with pytest.raises(NotImplementedError): 123 toolset.add( 124 Tool( 125 name="test", 126 description="test", 127 parameters={"type": "object", "properties": {}}, 128 function=lambda: None, 129 ) 130 ) 131 132 def test_clear(self, large_catalog): 133 toolset = SearchableToolset(catalog=large_catalog) 134 toolset.warm_up() 135 toolset._bootstrap_tool.invoke(tool_keywords="weather temperature city") 136 assert len(toolset._discovered_tools) > 0 137 toolset.clear() 138 assert len(toolset._discovered_tools) == 0 139 140 141 class TestSearchableToolsetPassthrough: 142 """Tests for passthrough mode (small catalogs).""" 143 144 def test_passthrough_mode_detected(self, small_catalog): 145 """Test that small catalogs trigger passthrough mode.""" 146 toolset = SearchableToolset(catalog=small_catalog) 147 toolset.warm_up() 148 149 assert toolset._is_passthrough() is True 150 151 def test_passthrough_exposes_all_tools(self, small_catalog): 152 """Test that passthrough mode exposes all catalog tools.""" 153 toolset = SearchableToolset(catalog=small_catalog) 154 toolset.warm_up() 155 156 assert len(toolset) == len(small_catalog) 157 158 tool_names = [tool.name for tool in toolset] 159 assert "get_weather" in tool_names 160 assert "add_numbers" in tool_names 161 assert "multiply_numbers" in tool_names 162 163 def test_passthrough_no_bootstrap_tool(self, small_catalog): 164 """Test that passthrough mode doesn't create bootstrap tool.""" 165 toolset = SearchableToolset(catalog=small_catalog) 166 toolset.warm_up() 167 168 assert toolset._bootstrap_tool is None 169 170 def test_passthrough_contains_by_name(self, small_catalog): 171 """Test __contains__ by name in passthrough mode.""" 172 toolset = SearchableToolset(catalog=small_catalog) 173 toolset.warm_up() 174 175 assert "get_weather" in toolset 176 assert "nonexistent" not in toolset 177 178 def test_passthrough_contains_by_tool(self, small_catalog, weather_tool): 179 """Test __contains__ by tool instance in passthrough mode.""" 180 toolset = SearchableToolset(catalog=small_catalog) 181 toolset.warm_up() 182 183 assert weather_tool in toolset 184 185 def test_passthrough_contains_by_tool_invalid_type(self, small_catalog): 186 toolset = SearchableToolset(catalog=small_catalog) 187 toolset.warm_up() 188 189 with pytest.raises(TypeError): 190 123 in toolset # noqa: B015 191 192 def test_custom_search_threshold(self, large_catalog): 193 """Test that custom search_threshold changes passthrough behavior.""" 194 # With threshold of 10, 8 tools should be passthrough 195 toolset = SearchableToolset(catalog=large_catalog, search_threshold=10) 196 toolset.warm_up() 197 198 assert toolset._is_passthrough() is True 199 assert len(list(toolset)) == 8 200 201 202 class TestSearchableToolsetBM25Mode: 203 """Tests for BM25 discovery mode.""" 204 205 def test_bm25_mode_creates_search_tools(self, large_catalog): 206 """Test that BM25 mode creates search_tools bootstrap tool.""" 207 toolset = SearchableToolset(catalog=large_catalog) 208 toolset.warm_up() 209 210 assert toolset._bootstrap_tool is not None 211 assert toolset._bootstrap_tool.name == "search_tools" 212 213 def test_bm25_mode_initializes_document_store(self, large_catalog): 214 """Test that BM25 mode initializes document store.""" 215 toolset = SearchableToolset(catalog=large_catalog) 216 toolset.warm_up() 217 218 assert toolset._document_store is not None 219 220 def test_search_tools_finds_relevant_tools(self, large_catalog): 221 """Test that search_tools finds relevant tools.""" 222 toolset = SearchableToolset(catalog=large_catalog, top_k=3) 223 toolset.warm_up() 224 assert toolset._bootstrap_tool is not None 225 226 result = toolset._bootstrap_tool.invoke(tool_keywords="weather temperature city") 227 228 assert "get_weather" in result 229 assert "get_weather" in toolset._discovered_tools 230 231 def test_search_tools_auto_loads(self, large_catalog): 232 """Test that search_tools auto-loads found tools.""" 233 toolset = SearchableToolset(catalog=large_catalog, top_k=3) 234 toolset.warm_up() 235 assert toolset._bootstrap_tool is not None 236 237 # Initially only bootstrap tool 238 assert len(toolset) == 1 239 240 toolset._bootstrap_tool.invoke(tool_keywords="add numbers multiply") 241 242 # Should have bootstrap + discovered tools 243 assert len(toolset) > 1 244 245 def test_search_tools_respects_k(self, large_catalog): 246 """Test that search_tools respects k parameter.""" 247 toolset = SearchableToolset(catalog=large_catalog, top_k=2) 248 toolset.warm_up() 249 assert toolset._bootstrap_tool is not None 250 251 # Search with explicit k=1 252 result = toolset._bootstrap_tool.invoke(tool_keywords="add numbers together", k=1) 253 254 # Should find exactly 1 tool 255 assert "Found and loaded 1 tool(s):" in result 256 257 def test_search_tools_no_results(self, large_catalog): 258 """Test search_tools with no matching results.""" 259 toolset = SearchableToolset(catalog=large_catalog) 260 toolset.warm_up() 261 assert toolset._bootstrap_tool is not None 262 263 result = toolset._bootstrap_tool.invoke(tool_keywords="xyznonexistent123") 264 265 assert "No tools found" in result 266 assert len(toolset._discovered_tools) == 0 267 268 def test_search_tools_no_keywords(self, large_catalog): 269 """Test search_tools with no keywords.""" 270 toolset = SearchableToolset(catalog=large_catalog) 271 toolset.warm_up() 272 assert toolset._bootstrap_tool is not None 273 274 result = toolset._bootstrap_tool.invoke(tool_keywords="") 275 assert "No tool keywords provided" in result 276 assert len(toolset._discovered_tools) == 0 277 278 279 class TestSearchableToolsetIteration: 280 """Tests for iteration and collection behavior.""" 281 282 def test_iter_passthrough(self, small_catalog): 283 """Test iteration in passthrough mode.""" 284 toolset = SearchableToolset(catalog=small_catalog) 285 toolset.warm_up() 286 287 tools = list(toolset) 288 289 assert len(tools) == len(small_catalog) 290 291 def test_iter_with_discovered_tools(self, large_catalog): 292 """Test iteration with discovered tools.""" 293 toolset = SearchableToolset(catalog=large_catalog) 294 toolset.warm_up() 295 assert toolset._bootstrap_tool is not None 296 297 # Search for tools 298 toolset._bootstrap_tool.invoke(tool_keywords="weather") 299 toolset._bootstrap_tool.invoke(tool_keywords="math addition") 300 301 tools = list(toolset) 302 303 assert len(tools) >= 2 # bootstrap + discovered tools 304 tool_names = [t.name for t in tools] 305 assert "search_tools" in tool_names 306 assert "get_weather" in tool_names 307 308 def test_iter_automatically_warms_up(self, large_catalog): 309 toolset = SearchableToolset(catalog=large_catalog) 310 assert not toolset._warmed_up 311 312 list(toolset) 313 assert toolset._warmed_up 314 315 def test_contains_bootstrap_tool(self, large_catalog): 316 """Test __contains__ for bootstrap tool.""" 317 toolset = SearchableToolset(catalog=large_catalog) 318 toolset.warm_up() 319 320 assert "search_tools" in toolset 321 assert toolset._bootstrap_tool in toolset 322 323 def test_contains_discovered_tool(self, large_catalog): 324 """Test __contains__ for discovered tools.""" 325 toolset = SearchableToolset(catalog=large_catalog, top_k=1) 326 toolset.warm_up() 327 assert toolset._bootstrap_tool is not None 328 329 toolset._bootstrap_tool.invoke(tool_keywords="weather") 330 331 assert "get_weather" in toolset 332 assert "add_numbers" not in toolset # Not discovered yet 333 334 def test_getitem(self, large_catalog): 335 toolset = SearchableToolset(catalog=large_catalog) 336 toolset.warm_up() 337 338 tool = toolset[0] 339 assert tool.name == "search_tools" 340 341 342 class TestSearchableToolsetSerialization: 343 """Tests for serialization and deserialization.""" 344 345 def test_to_dict(self, large_catalog): 346 """Test serialization to dict.""" 347 toolset = SearchableToolset(catalog=large_catalog, top_k=3, search_threshold=5) 348 349 data = toolset.to_dict() 350 351 assert "type" in data 352 assert "haystack.tools.searchable_toolset.SearchableToolset" in data["type"] 353 assert "data" in data 354 assert data["data"]["top_k"] == 3 355 assert data["data"]["search_threshold"] == 5 356 assert len(data["data"]["catalog"]) == len(large_catalog) 357 358 def test_to_dict_with_toolset(self, large_catalog): 359 360 toolset = Toolset(tools=large_catalog) 361 362 searchable_toolset = SearchableToolset(catalog=toolset) 363 data = searchable_toolset.to_dict() 364 assert "type" in data 365 assert "haystack.tools.searchable_toolset.SearchableToolset" in data["type"] 366 assert "data" in data 367 assert data["data"]["top_k"] == 3 368 assert data["data"]["search_threshold"] == 8 369 assert len(data["data"]["catalog"]) == 1 370 assert data["data"]["catalog"][0]["type"] == "haystack.tools.toolset.Toolset" 371 372 def test_from_dict(self, large_catalog): 373 """Test deserialization from dict.""" 374 toolset = SearchableToolset(catalog=large_catalog, top_k=3) 375 376 data = toolset.to_dict() 377 restored = SearchableToolset.from_dict(data) 378 restored.warm_up() 379 380 assert restored._top_k == 3 381 assert len(restored._catalog) == len(large_catalog) 382 383 def test_from_dict_with_invalid_item_type(self): 384 data = { 385 "type": "haystack.tools.searchable_toolset.SearchableToolset", 386 "data": {"catalog": [{"type": "haystack.dataclasses.Document", "data": "irrelevant"}]}, 387 } 388 with pytest.raises(TypeError): 389 SearchableToolset.from_dict(data) 390 391 def test_serde_roundtrip(self, large_catalog): 392 """Test full serialization roundtrip.""" 393 toolset = SearchableToolset(catalog=large_catalog, top_k=5, search_threshold=6) 394 toolset.warm_up() 395 396 # Serialize 397 data = toolset.to_dict() 398 399 # Deserialize 400 restored = SearchableToolset.from_dict(data) 401 restored.warm_up() 402 assert restored._bootstrap_tool is not None 403 404 # Verify behavior matches 405 assert restored._is_passthrough() == toolset._is_passthrough() 406 407 # Verify bootstrap tool works 408 result = restored._bootstrap_tool.invoke(tool_keywords="weather") 409 assert "get_weather" in result 410 assert "get_weather" in restored._discovered_tools 411 412 def test_serde_preserves_catalog_tools(self, large_catalog): 413 """Test that serialization preserves catalog tool functionality.""" 414 toolset = SearchableToolset(catalog=large_catalog) 415 toolset.warm_up() 416 417 data = toolset.to_dict() 418 restored = SearchableToolset.from_dict(data) 419 restored.warm_up() 420 assert restored._bootstrap_tool is not None 421 422 # Search and invoke a tool 423 result_text = restored._bootstrap_tool.invoke(tool_keywords="add numbers") 424 assert "add_numbers" in result_text 425 add_tool = restored._discovered_tools["add_numbers"] 426 result = add_tool.invoke(a=10, b=5) 427 428 assert result == 15 429 430 431 class TestSearchableToolsetWithToolset: 432 """Tests for using a Toolset as catalog input.""" 433 434 def test_accepts_toolset_as_catalog(self, small_catalog): 435 """Test that a Toolset can be used as catalog.""" 436 base_toolset = Toolset(tools=small_catalog) 437 search_toolset = SearchableToolset(catalog=base_toolset, search_threshold=10) 438 search_toolset.warm_up() 439 440 assert len(search_toolset._catalog) == len(small_catalog) 441 442 def test_toolset_catalog_passthrough(self, small_catalog): 443 """Test passthrough mode with Toolset catalog.""" 444 base_toolset = Toolset(tools=small_catalog) 445 search_toolset = SearchableToolset(catalog=base_toolset) 446 search_toolset.warm_up() 447 448 assert search_toolset._is_passthrough() is True 449 assert len(list(search_toolset)) == len(small_catalog) 450 451 def test_accepts_list_of_toolsets(self, weather_tool, add_tool, multiply_tool, stock_tool): 452 """Test that a list of Toolsets can be used as catalog.""" 453 toolset1 = Toolset(tools=[weather_tool, add_tool]) 454 toolset2 = Toolset(tools=[multiply_tool, stock_tool]) 455 456 search_toolset = SearchableToolset(catalog=[toolset1, toolset2], search_threshold=10) 457 search_toolset.warm_up() 458 459 assert len(search_toolset._catalog) == 4 460 assert any(t.name == "get_weather" for t in search_toolset._catalog) 461 assert any(t.name == "add_numbers" for t in search_toolset._catalog) 462 assert any(t.name == "multiply_numbers" for t in search_toolset._catalog) 463 assert any(t.name == "get_stock_price" for t in search_toolset._catalog) 464 465 def test_accepts_mixed_list(self, weather_tool, add_tool, multiply_tool): 466 """Test that a mixed list of Tools and Toolsets can be used as catalog.""" 467 toolset = Toolset(tools=[add_tool, multiply_tool]) 468 469 search_toolset = SearchableToolset(catalog=[weather_tool, toolset], search_threshold=10) 470 search_toolset.warm_up() 471 472 assert len(search_toolset._catalog) == 3 473 assert any(t.name == "get_weather" for t in search_toolset._catalog) 474 assert any(t.name == "add_numbers" for t in search_toolset._catalog) 475 assert any(t.name == "multiply_numbers" for t in search_toolset._catalog) 476 477 478 class TestSearchableToolsetWarmUp: 479 """Tests for warm_up behavior.""" 480 481 def test_warm_up_idempotent(self, large_catalog): 482 """Test that warm_up can be called multiple times safely.""" 483 toolset = SearchableToolset(catalog=large_catalog) 484 485 toolset.warm_up() 486 first_bootstrap = toolset._bootstrap_tool 487 488 toolset.warm_up() 489 second_bootstrap = toolset._bootstrap_tool 490 491 # Should be the same instance 492 assert first_bootstrap is second_bootstrap 493 494 def test_catalog_empty_before_warm_up(self, small_catalog): 495 """Test that catalog is empty before warm_up (deferred flattening).""" 496 toolset = SearchableToolset(catalog=small_catalog) 497 498 # Before warm_up, _catalog is empty (flattening is deferred) 499 assert len(toolset._catalog) == 0 500 501 # After warm_up, catalog is populated 502 toolset.warm_up() 503 assert len(list(toolset)) == len(small_catalog) 504 505 def test_bootstrap_tool_before_warm_up(self, large_catalog): 506 """Test that bootstrap tool is None before warm_up.""" 507 toolset = SearchableToolset(catalog=large_catalog) 508 509 assert toolset._bootstrap_tool is None 510 511 512 class TestSearchableToolsetEdgeCases: 513 """Tests for edge cases and error handling.""" 514 515 def test_empty_catalog(self): 516 """Test with empty catalog.""" 517 toolset = SearchableToolset(catalog=[]) 518 toolset.warm_up() 519 520 assert toolset._is_passthrough() is True 521 assert len(list(toolset)) == 0 522 523 def test_single_tool_catalog(self, weather_tool): 524 """Test with single tool catalog.""" 525 toolset = SearchableToolset(catalog=[weather_tool]) 526 toolset.warm_up() 527 528 assert toolset._is_passthrough() is True 529 assert len(list(toolset)) == 1 530 531 def test_exactly_at_threshold(self): 532 """Test catalog size exactly at threshold.""" 533 tools = [ 534 Tool( 535 name=f"tool_{i}", 536 description=f"Tool number {i}", 537 parameters={"type": "object", "properties": {}}, 538 function=lambda: None, 539 ) 540 for i in range(8) 541 ] 542 543 toolset = SearchableToolset(catalog=tools, search_threshold=8) 544 toolset.warm_up() 545 546 # Should NOT be passthrough (>= threshold triggers discovery) 547 assert toolset._is_passthrough() is False 548 549 def test_one_below_threshold(self): 550 """Test catalog size one below threshold.""" 551 tools = [ 552 Tool( 553 name=f"tool_{i}", 554 description=f"Tool number {i}", 555 parameters={"type": "object", "properties": {}}, 556 function=lambda: None, 557 ) 558 for i in range(7) 559 ] 560 561 toolset = SearchableToolset(catalog=tools, search_threshold=8) 562 toolset.warm_up() 563 564 # Should be passthrough 565 assert toolset._is_passthrough() is True 566 567 def test_multiple_loads_same_tool(self, large_catalog): 568 """Test searching for the same tool multiple times.""" 569 toolset = SearchableToolset(catalog=large_catalog) 570 toolset.warm_up() 571 assert toolset._bootstrap_tool is not None 572 573 # Search for same tool twice 574 toolset._bootstrap_tool.invoke(tool_keywords="weather") 575 toolset._bootstrap_tool.invoke(tool_keywords="weather temperature") 576 577 # Should still only have discovered tools (may be multiple if they match "weather") 578 assert "get_weather" in toolset._discovered_tools 579 assert len(toolset) >= 2 # bootstrap + discovered tools 580 581 582 class TestSearchableToolsetLazyToolset: 583 """Tests for lazy toolsets (e.g. MCPToolset with eager_connect=False).""" 584 585 def test_lazy_toolset_tools_available_after_warm_up(self): 586 """Test that a lazy toolset's tools become available after warm_up.""" 587 588 class LazyToolset(Toolset): 589 """A toolset that only provides real tools after warm_up (like MCPToolset).""" 590 591 def __init__(self): 592 # Before warm_up, no real tools — mimics MCPToolset with eager_connect=False 593 super().__init__(tools=[]) 594 self._connected = False 595 596 def warm_up(self) -> None: 597 if self._connected: 598 return 599 # Simulate connecting and discovering tools 600 self.tools = [ 601 Tool( 602 name=f"lazy_tool_{i}", 603 description=f"Lazy tool number {i} for testing", 604 parameters={"type": "object", "properties": {"x": {"type": "string"}}}, 605 function=lambda x: x, 606 ) 607 for i in range(10) 608 ] 609 self._connected = True 610 611 lazy = LazyToolset() 612 # Before warm_up, iterating yields nothing 613 assert len(list(lazy)) == 0 614 615 toolset = SearchableToolset(catalog=lazy) 616 # Before warm_up, catalog is empty 617 assert len(toolset._catalog) == 0 618 619 toolset.warm_up() 620 621 # After warm_up, all 10 lazy tools should be in the catalog 622 assert len(toolset._catalog) == 10 623 # 10 tools >= 8 threshold -> BM25 mode 624 assert toolset._is_passthrough() is False 625 assert toolset._bootstrap_tool is not None 626 627 # Search should find lazy tools 628 result = toolset._bootstrap_tool.invoke(tool_keywords="lazy tool testing") 629 assert "lazy_tool" in result 630 631 def test_lazy_toolset_passthrough_mode(self): 632 """Test lazy toolset with few tools ends up in passthrough mode.""" 633 634 class SmallLazyToolset(Toolset): 635 def __init__(self): 636 super().__init__(tools=[]) 637 638 def warm_up(self) -> None: 639 self.tools = [ 640 Tool( 641 name="lazy_single", 642 description="A single lazy tool", 643 parameters={"type": "object", "properties": {}}, 644 function=lambda: "result", 645 ) 646 ] 647 648 toolset = SearchableToolset(catalog=SmallLazyToolset()) 649 toolset.warm_up() 650 651 assert toolset._is_passthrough() is True 652 assert len(list(toolset)) == 1 653 assert "lazy_single" in toolset 654 655 def test_mixed_lazy_and_eager_toolsets(self): 656 """Test catalog with both lazy and eager toolsets.""" 657 658 class LazyToolset(Toolset): 659 def __init__(self): 660 super().__init__(tools=[]) 661 662 def warm_up(self) -> None: 663 self.tools = [ 664 Tool( 665 name=f"lazy_{i}", 666 description=f"Lazy tool {i}", 667 parameters={"type": "object", "properties": {}}, 668 function=lambda: None, 669 ) 670 for i in range(5) 671 ] 672 673 eager_tools = [ 674 Tool( 675 name=f"eager_{i}", 676 description=f"Eager tool {i}", 677 parameters={"type": "object", "properties": {}}, 678 function=lambda: None, 679 ) 680 for i in range(5) 681 ] 682 683 toolset = SearchableToolset(catalog=[LazyToolset()] + eager_tools) 684 toolset.warm_up() 685 686 # Should have 5 lazy + 5 eager = 10 tools 687 assert len(toolset._catalog) == 10 688 assert toolset._is_passthrough() is False 689 assert any(t.name == "lazy_0" for t in toolset._catalog) 690 assert any(t.name == "eager_0" for t in toolset._catalog) 691 692 693 class TestSearchableToolsetCustomSearchTool: 694 """Tests for customizing the bootstrap search tool.""" 695 696 def test_invalid_parameters_description_key(self): 697 """Test that invalid parameter keys raise ValueError.""" 698 with pytest.raises(ValueError, match="Invalid search_tool_parameters_description keys"): 699 SearchableToolset(catalog=[], search_tool_parameters_description={"invalid_key": "some description"}) 700 701 def test_custom_tool_behavior(self, large_catalog): 702 """Test custom name, description, parameter overrides, collection access, and discovery.""" 703 toolset = SearchableToolset( 704 catalog=large_catalog, 705 search_tool_name="find_tools", 706 search_tool_description="Find tools by keyword.", 707 search_tool_parameters_description={"tool_keywords": "Keywords.", "k": "How many."}, 708 ) 709 toolset.warm_up() 710 assert toolset._bootstrap_tool is not None 711 712 # Custom name and description applied 713 assert toolset._bootstrap_tool.name == "find_tools" 714 assert toolset._bootstrap_tool.description == "Find tools by keyword." 715 716 # Both parameter descriptions overridden 717 props = toolset._bootstrap_tool.parameters["properties"] 718 assert props["tool_keywords"]["description"] == "Keywords." 719 assert props["k"]["description"] == "How many." 720 721 # Collection access uses custom name 722 assert "find_tools" in toolset 723 assert "search_tools" not in toolset 724 assert toolset[0].name == "find_tools" 725 726 # Discovery still works 727 result = toolset._bootstrap_tool.invoke(tool_keywords="weather") 728 assert "get_weather" in result 729 assert "get_weather" in toolset._discovered_tools 730 731 def test_serialization(self, large_catalog): 732 """Test to_dict includes all fields and serde roundtrip preserves settings.""" 733 toolset = SearchableToolset( 734 catalog=large_catalog, 735 search_tool_name="find_tools", 736 search_tool_description="Custom description.", 737 search_tool_parameters_description={"tool_keywords": "Custom param desc."}, 738 ) 739 740 # to_dict includes custom values 741 data = toolset.to_dict() 742 assert data["data"]["search_tool_name"] == "find_tools" 743 assert data["data"]["search_tool_description"] == "Custom description." 744 assert data["data"]["search_tool_parameters_description"] == {"tool_keywords": "Custom param desc."} 745 746 # Roundtrip preserves behavior 747 restored = SearchableToolset.from_dict(data) 748 restored.warm_up() 749 assert restored._bootstrap_tool is not None 750 assert restored._bootstrap_tool.name == "find_tools" 751 assert restored._bootstrap_tool.description == "Custom description." 752 assert restored._bootstrap_tool.parameters["properties"]["tool_keywords"]["description"] == "Custom param desc." 753 result = restored._bootstrap_tool.invoke(tool_keywords="weather") 754 assert "get_weather" in result 755 756 757 @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set") 758 @pytest.mark.integration 759 class TestSearchableToolsetAgentIntegration: 760 """Integration tests with real Agent and OpenAIChatGenerator.""" 761 762 def test_agent_discovers_and_uses_tools(self, large_catalog): 763 """Agent discovers tools via BM25 search and uses them.""" 764 from haystack.components.agents import Agent 765 from haystack.components.generators.chat import OpenAIChatGenerator 766 from haystack.dataclasses import ChatMessage 767 768 toolset = SearchableToolset(catalog=large_catalog, top_k=2, search_threshold=3) 769 agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"), tools=toolset, max_agent_steps=5) 770 771 assert len(agent.tools) == 1 772 result = agent.run(messages=[ChatMessage.from_user("What's the weather in Milan?")]) 773 774 assert len(agent.tools) > 1 775 assert "messages" in result 776 messages = result["messages"] 777 assert len(messages) > 1 778 779 tool_calls = [tool_call for msg in messages if msg.tool_calls for tool_call in msg.tool_calls] 780 assert len(tool_calls) > 1 781 assert any(tool_call.tool_name == "search_tools" for tool_call in tool_calls) 782 assert any(tool_call.tool_name == "get_weather" for tool_call in tool_calls) 783 assert "22" in messages[-1].text