/ test / tools / test_searchable_toolset.py
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