/ test / tools / test_component_tool.py
test_component_tool.py
  1  # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
  2  #
  3  # SPDX-License-Identifier: Apache-2.0
  4  
  5  import json
  6  import os
  7  from dataclasses import dataclass
  8  from typing import Any
  9  from unittest.mock import patch
 10  
 11  import pytest
 12  from openai.types.chat import ChatCompletion, ChatCompletionMessage
 13  from openai.types.chat.chat_completion import Choice
 14  
 15  from haystack import Pipeline, SuperComponent, component
 16  from haystack.components.agents import Agent, State
 17  from haystack.components.builders import PromptBuilder
 18  from haystack.components.generators.chat import OpenAIChatGenerator
 19  from haystack.components.tools import ToolInvoker
 20  from haystack.components.websearch.serper_dev import SerperDevWebSearch
 21  from haystack.core.pipeline.utils import _deepcopy_with_exceptions
 22  from haystack.dataclasses import ChatMessage, ChatRole, Document
 23  from haystack.tools import ComponentTool, ToolsType
 24  from haystack.utils.auth import Secret
 25  from test.tools.test_parameters_schema_utils import BYTE_STREAM_SCHEMA, DOCUMENT_SCHEMA, SPARSE_EMBEDDING_SCHEMA
 26  
 27  # Component and Model Definitions
 28  
 29  
 30  @component
 31  class SimpleComponentUsingChatMessages:
 32      """A simple component that generates text."""
 33  
 34      @component.output_types(reply=str)
 35      def run(self, messages: list[ChatMessage]) -> dict[str, str]:
 36          """
 37          A simple component that generates text.
 38  
 39          :param messages: Users messages
 40          :return: A dictionary with the generated text.
 41          """
 42          return {"reply": f"Hello, {messages[0].text}!"}
 43  
 44  
 45  @component
 46  class SimpleComponent:
 47      """A simple component that generates text."""
 48  
 49      @component.output_types(reply=str)
 50      def run(self, text: str) -> dict[str, str]:
 51          """
 52          A simple component that generates text.
 53  
 54          :param text: user's name
 55          :return: A dictionary with the generated text.
 56          """
 57          return {"reply": f"Hello, {text}!"}
 58  
 59  
 60  def reply_formatter(input_text: str) -> str:
 61      return f"Formatted reply: {input_text}"
 62  
 63  
 64  @dataclass
 65  class User:
 66      """A simple user dataclass."""
 67  
 68      name: str = "Anonymous"
 69      age: int = 0
 70  
 71  
 72  @component
 73  class UserGreeter:
 74      """A simple component that processes a User."""
 75  
 76      @component.output_types(message=str)
 77      def run(self, user: User) -> dict[str, str]:
 78          """
 79          A simple component that processes a User.
 80  
 81          :param user: The User object to process.
 82          :return: A dictionary with a message about the user.
 83          """
 84          return {"message": f"User {user.name} is {user.age} years old"}
 85  
 86  
 87  @component
 88  class ListProcessor:
 89      """A component that processes a list of strings."""
 90  
 91      @component.output_types(concatenated=str)
 92      def run(self, texts: list[str]) -> dict[str, str]:
 93          """
 94          Concatenates a list of strings into a single string.
 95  
 96          :param texts: The list of strings to concatenate.
 97          :return: A dictionary with the concatenated string.
 98          """
 99          return {"concatenated": " ".join(texts)}
100  
101  
102  @dataclass
103  class Address:
104      """A dataclass representing a physical address."""
105  
106      street: str
107      city: str
108  
109  
110  @dataclass
111  class Person:
112      """A person with an address."""
113  
114      name: str
115      address: Address
116  
117  
118  @component
119  class PersonProcessor:
120      """A component that processes a Person with nested Address."""
121  
122      @component.output_types(info=str)
123      def run(self, person: Person) -> dict[str, str]:
124          """
125          Creates information about the person.
126  
127          :param person: The Person to process.
128          :return: A dictionary with the person's information.
129          """
130          return {"info": f"{person.name} lives at {person.address.street}, {person.address.city}."}
131  
132  
133  @component
134  class DocumentProcessor:
135      """A component that processes a list of Documents."""
136  
137      @component.output_types(concatenated=str)
138      def run(self, documents: list[Document], top_k: int = 5) -> dict[str, str]:
139          """
140          Concatenates the content of multiple documents with newlines.
141  
142          :param documents: List of Documents whose content will be concatenated
143          :param top_k: The number of top documents to concatenate
144          :returns: Dictionary containing the concatenated document contents
145          """
146          return {"concatenated": "\n".join(doc.content for doc in documents[:top_k])}
147  
148  
149  @component
150  class FakeChatGenerator:
151      def __init__(self, messages: list[ChatMessage]):
152          self.messages = messages
153  
154      @component.output_types(replies=list[ChatMessage])
155      def run(
156          self,
157          messages: list[ChatMessage],
158          generation_kwargs: dict[str, Any] | None = None,
159          *,
160          tools: ToolsType | None = None,
161      ) -> dict[str, list[ChatMessage]]:
162          return {"replies": self.messages}
163  
164  
165  def output_handler(old, new):
166      """
167      Output handler to test serialization.
168      """
169      return old + new
170  
171  
172  class TestComponentTool:
173      def test_from_component_basic(self):
174          tool = ComponentTool(component=SimpleComponent())
175  
176          assert tool.name == "simple_component"
177          assert tool.description == "A simple component that generates text."
178          assert tool.parameters == {
179              "type": "object",
180              "description": "A simple component that generates text.",
181              "properties": {"text": {"type": "string", "description": "user's name"}},
182              "required": ["text"],
183          }
184  
185          # Test tool invocation
186          result = tool.invoke(text="world")
187          assert isinstance(result, dict)
188          assert "reply" in result
189          assert result["reply"] == "Hello, world!"
190  
191      def test_from_component_long_description(self):
192          tool = ComponentTool(component=SimpleComponent(), description="".join(["A"] * 1024))
193          assert len(tool.description) == 1024
194  
195      def test_from_component_with_inputs_from_state(self):
196          tool = ComponentTool(component=SimpleComponent(), inputs_from_state={"text": "text"})
197          assert tool.inputs_from_state == {"text": "text"}
198          # Inputs should be excluded from schema generation
199          assert tool.parameters == {
200              "type": "object",
201              "properties": {},
202              "description": "A simple component that generates text.",
203          }
204  
205      def test_from_component_with_inputs_from_state_different_names(self):
206          tool = ComponentTool(component=SimpleComponent(), inputs_from_state={"state_text": "text"})
207          assert tool.inputs_from_state == {"state_text": "text"}
208          # Inputs should be excluded from schema generation
209          assert tool.parameters == {
210              "type": "object",
211              "properties": {},
212              "description": "A simple component that generates text.",
213          }
214  
215      def test_from_component_with_invalid_inputs_from_state_nested_dict(self):
216          """Test that ComponentTool rejects nested dict format for inputs_from_state"""
217          with pytest.raises(TypeError, match="must be str, not dict"):
218              ComponentTool(component=SimpleComponent(), inputs_from_state={"documents": {"source": "documents"}})
219  
220      def test_from_component_with_outputs_to_state(self):
221          tool = ComponentTool(component=SimpleComponent(), outputs_to_state={"replies": {"source": "reply"}})
222          assert tool.outputs_to_state == {"replies": {"source": "reply"}}
223  
224      def test_from_component_with_invalid_outputs_to_state_source(self):
225          """Test that ComponentTool validates outputs_to_state source against component outputs"""
226          with pytest.raises(ValueError, match="unknown output"):
227              ComponentTool(component=SimpleComponent(), outputs_to_state={"result": {"source": "nonexistent"}})
228  
229      def test_from_component_with_dataclass(self):
230          tool = ComponentTool(component=UserGreeter())
231          assert tool.parameters == {
232              "$defs": {
233                  "User": {
234                      "properties": {
235                          "name": {"description": "Field 'name' of 'User'.", "type": "string", "default": "Anonymous"},
236                          "age": {"description": "Field 'age' of 'User'.", "type": "integer", "default": 0},
237                      },
238                      "type": "object",
239                  }
240              },
241              "description": "A simple component that processes a User.",
242              "properties": {"user": {"$ref": "#/$defs/User", "description": "The User object to process."}},
243              "required": ["user"],
244              "type": "object",
245          }
246  
247          assert tool.name == "user_greeter"
248          assert tool.description == "A simple component that processes a User."
249  
250          # Test tool invocation
251          result = tool.invoke(user={"name": "Alice", "age": 30})
252          assert isinstance(result, dict)
253          assert "message" in result
254          assert result["message"] == "User Alice is 30 years old"
255  
256      def test_from_component_with_list_input(self):
257          tool = ComponentTool(
258              component=ListProcessor(), name="list_processing_tool", description="A tool that concatenates strings"
259          )
260  
261          assert tool.parameters == {
262              "type": "object",
263              "description": "Concatenates a list of strings into a single string.",
264              "properties": {
265                  "texts": {
266                      "type": "array",
267                      "description": "The list of strings to concatenate.",
268                      "items": {"type": "string"},
269                  }
270              },
271              "required": ["texts"],
272          }
273  
274          # Test tool invocation
275          result = tool.invoke(texts=["hello", "world"])
276          assert isinstance(result, dict)
277          assert "concatenated" in result
278          assert result["concatenated"] == "hello world"
279  
280      def test_from_component_with_nested_dataclass(self):
281          tool = ComponentTool(
282              component=PersonProcessor(), name="person_tool", description="A tool that processes people"
283          )
284  
285          assert tool.parameters == {
286              "$defs": {
287                  "Address": {
288                      "properties": {
289                          "street": {"description": "Field 'street' of 'Address'.", "type": "string"},
290                          "city": {"description": "Field 'city' of 'Address'.", "type": "string"},
291                      },
292                      "required": ["street", "city"],
293                      "type": "object",
294                  },
295                  "Person": {
296                      "properties": {
297                          "name": {"description": "Field 'name' of 'Person'.", "type": "string"},
298                          "address": {"$ref": "#/$defs/Address", "description": "Field 'address' of 'Person'."},
299                      },
300                      "required": ["name", "address"],
301                      "type": "object",
302                  },
303              },
304              "description": "Creates information about the person.",
305              "properties": {"person": {"$ref": "#/$defs/Person", "description": "The Person to process."}},
306              "required": ["person"],
307              "type": "object",
308          }
309  
310          # Test tool invocation
311          result = tool.invoke(person={"name": "Diana", "address": {"street": "123 Elm Street", "city": "Metropolis"}})
312          assert isinstance(result, dict)
313          assert "info" in result
314          assert result["info"] == "Diana lives at 123 Elm Street, Metropolis."
315  
316      def test_from_component_with_list_of_documents(self):
317          tool = ComponentTool(
318              component=DocumentProcessor(),
319              name="document_processor",
320              description="A tool that concatenates document contents",
321          )
322  
323          assert tool.parameters == {
324              "$defs": {
325                  "ByteStream": BYTE_STREAM_SCHEMA,
326                  "Document": DOCUMENT_SCHEMA,
327                  "SparseEmbedding": SPARSE_EMBEDDING_SCHEMA,
328              },
329              "description": "Concatenates the content of multiple documents with newlines.",
330              "properties": {
331                  "documents": {
332                      "description": "List of Documents whose content will be concatenated",
333                      "items": {"$ref": "#/$defs/Document"},
334                      "type": "array",
335                  },
336                  "top_k": {"description": "The number of top documents to concatenate", "type": "integer", "default": 5},
337              },
338              "required": ["documents"],
339              "type": "object",
340          }
341  
342          # Test tool invocation
343          result = tool.invoke(documents=[{"content": "First document"}, {"content": "Second document"}])
344          assert isinstance(result, dict)
345          assert "concatenated" in result
346          assert result["concatenated"] == "First document\nSecond document"
347  
348      def test_from_component_with_dynamic_input_types(self):
349          builder = PromptBuilder(template="Hello, {{name}}!")
350          tool = ComponentTool(component=builder, name="prompt_builder_tool")
351          assert tool.parameters == {
352              "description": "Renders the prompt template with the provided variables.",
353              "properties": {
354                  "name": {"default": "", "description": "Input 'name' for the component."},
355                  "template": {
356                      "anyOf": [{"type": "string"}, {"type": "null"}],
357                      "default": None,
358                      "description": "An optional string template to overwrite PromptBuilder's default template. If "
359                      "None, the default template\nprovided at initialization is used.",
360                  },
361                  "template_variables": {
362                      "anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}],
363                      "default": None,
364                      "description": "An optional dictionary of template variables to overwrite the pipeline variables.",
365                  },
366              },
367              "type": "object",
368          }
369  
370      def test_from_component_with_invalid_component(self):
371          class NotAComponent:
372              def foo(self, text: str):
373                  return {"reply": f"Hello, {text}!"}
374  
375          not_a_component = NotAComponent()
376  
377          with pytest.raises(TypeError):
378              ComponentTool(component=not_a_component, name="invalid_tool", description="This should fail")
379  
380      def test_component_invoker_with_chat_message_input(self):
381          tool = ComponentTool(
382              component=SimpleComponentUsingChatMessages(), name="simple_tool", description="A simple tool"
383          )
384          result = tool.invoke(messages=[ChatMessage.from_user(text="world")])
385          assert result == {"reply": "Hello, world!"}
386  
387      def test_component_tool_with_super_component_docstrings(self, monkeypatch):
388          """Test that ComponentTool preserves docstrings from underlying pipeline components in SuperComponents."""
389  
390          @component
391          class AnnotatedComponent:
392              """An annotated component with descriptive parameter docstrings."""
393  
394              @component.output_types(result=str)
395              def run(self, text: str, number: int = 42):
396                  """
397                  Process inputs and return result.
398  
399                  :param text: A detailed description of the text parameter that should be preserved
400                  :param number: A detailed description of the number parameter that should be preserved
401                  """
402                  return {"result": f"Processed: {text} and {number}"}
403  
404          # Create a pipeline with the annotated component
405          pipeline = Pipeline()
406          pipeline.add_component("processor", AnnotatedComponent())
407          # Create SuperComponent with mapping
408          super_comp = SuperComponent(
409              pipeline=pipeline,
410              input_mapping={"input_text": ["processor.text"], "input_number": ["processor.number"]},
411              output_mapping={"processor.result": "processed_result"},
412          )
413  
414          # Create ComponentTool from SuperComponent
415          tool = ComponentTool(component=super_comp, name="text_processor")
416  
417          # Verify that schema includes the docstrings from the original component
418          assert tool.parameters == {
419              "type": "object",
420              "description": "A component that combines: 'processor': Process inputs and return result.",
421              "properties": {
422                  "input_text": {
423                      "type": "string",
424                      "description": "Provided to the 'processor' component as: 'A detailed description of the text "
425                      "parameter that should be preserved'.",
426                  },
427                  "input_number": {
428                      "type": "integer",
429                      "description": "Provided to the 'processor' component as: 'A detailed description of the number "
430                      "parameter that should be preserved'.",
431                  },
432              },
433              "required": ["input_text"],
434          }
435  
436          # Test the tool functionality works
437          result = tool.invoke(input_text="Hello", input_number=42)
438          assert result["processed_result"] == "Processed: Hello and 42"
439  
440      def test_component_tool_with_multiple_mapped_docstrings(self):
441          """
442          Test ComponentTool combines docstrings from multiple components when a single input maps to multiple components.
443          """
444  
445          @component
446          class ComponentA:
447              """Component A with descriptive docstrings."""
448  
449              @component.output_types(output_a=str)
450              def run(self, query: str):
451                  """
452                  Process query in component A.
453  
454                  :param query: The query string for component A
455                  """
456                  return {"output_a": f"A processed: {query}"}
457  
458          @component
459          class ComponentB:
460              """Component B with descriptive docstrings."""
461  
462              @component.output_types(output_b=str)
463              def run(self, text: str):
464                  """
465                  Process text in component B.
466  
467                  :param text: Text to process in component B
468                  """
469                  return {"output_b": f"B processed: {text}"}
470  
471          # Create a pipeline with both components
472          pipeline = Pipeline()
473          pipeline.add_component("comp_a", ComponentA())
474          pipeline.add_component("comp_b", ComponentB())
475  
476          # Create SuperComponent with a single input mapped to both components
477          super_comp = SuperComponent(
478              pipeline=pipeline, input_mapping={"combined_input": ["comp_a.query", "comp_b.text"]}
479          )
480  
481          # Create ComponentTool from SuperComponent
482          tool = ComponentTool(component=super_comp, name="combined_processor")
483  
484          # Verify that schema includes combined docstrings from both components
485          assert tool.parameters == {
486              "type": "object",
487              "description": "A component that combines: 'comp_a': Process query in component A., 'comp_b': Process "
488              "text in component B.",
489              "properties": {
490                  "combined_input": {
491                      "type": "string",
492                      "description": "Provided to the 'comp_a' component as: 'The query string for component A', and "
493                      "Provided to the 'comp_b' component as: 'Text to process in component B'.",
494                  }
495              },
496              "required": ["combined_input"],
497          }
498  
499          # Test the tool functionality works
500          result = tool.invoke(combined_input="test input")
501          assert result["output_a"] == "A processed: test input"
502          assert result["output_b"] == "B processed: test input"
503  
504      def test_warm_up_is_idempotent(self):
505          """Test that calling warm_up multiple times only warms up the component once."""
506          from unittest.mock import MagicMock
507  
508          component = SimpleComponent()
509          component.warm_up = MagicMock()
510  
511          tool = ComponentTool(component=component)
512  
513          # Call warm_up multiple times
514          tool.warm_up()
515          tool.warm_up()
516          tool.warm_up()
517  
518          # Component's warm_up should only be called once
519          component.warm_up.assert_called_once()
520  
521      def test_from_component_with_callable_params_skipped(self, monkeypatch):
522          monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
523          agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"))
524  
525          tool = ComponentTool(
526              component=agent,
527              name="agent_tool",
528              description="An agent tool",
529              outputs_to_string={"source": "last_message"},
530          )
531  
532          assert tool.name == "agent_tool"
533          assert tool.description == "An agent tool"
534  
535          param_names = list(tool.parameters.get("properties", {}).keys())
536          assert "snapshot_callback" not in param_names
537          assert "streaming_callback" not in param_names
538          assert "messages" in param_names
539  
540      def test_from_component_with_state_param_excluded_from_schema(self):
541          @component
542          class ComponentWithState:
543              """A component that takes State as a direct input."""
544  
545              @component.output_types(result=str)
546              def run(self, query: str, state: State) -> dict:
547                  return {"result": query}
548  
549          tool = ComponentTool(component=ComponentWithState(), name="state_comp", description="test")
550  
551          param_names = list(tool.parameters.get("properties", {}).keys())
552          assert "state" not in param_names
553          assert "query" in param_names
554  
555      def test_from_component_with_optional_state_param_excluded_from_schema(self):
556          @component
557          class ComponentWithOptionalState:
558              """A component that takes Optional[State] as an input (e.g. ToolInvoker style)."""
559  
560              @component.output_types(result=str)
561              def run(self, query: str, state: State | None = None) -> dict:
562                  return {"result": query}
563  
564          tool = ComponentTool(component=ComponentWithOptionalState(), name="opt_state_comp", description="test")
565  
566          param_names = list(tool.parameters.get("properties", {}).keys())
567          assert "state" not in param_names
568          assert "query" in param_names
569  
570      def test_component_invoker_with_agent(self):
571          """Tests that Agent as a ComponentTool can be invoked when calling it with a list of dicts"""
572          agent = Agent(chat_generator=FakeChatGenerator(messages=[ChatMessage.from_assistant("Answer")]))
573          tool = ComponentTool(
574              component=agent,
575              name="agent_tool",
576              description="An agent tool",
577              outputs_to_string={"source": "last_message"},
578          )
579          result = tool.invoke(messages=[{"role": "user", "content": [{"text": "A 4-day trip in the south of France"}]}])
580          assert result["last_message"] == ChatMessage.from_assistant("Answer")
581  
582  
583  class TestComponentToolInPipeline:
584      @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
585      @pytest.mark.integration
586      def test_component_tool_in_pipeline(self):
587          # Create component and convert it to tool
588          tool = ComponentTool(
589              component=SimpleComponent(),
590              name="hello_tool",
591              description="A tool that generates a greeting message for the user",
592          )
593  
594          # Create pipeline with OpenAIChatGenerator and ToolInvoker
595          pipeline = Pipeline()
596          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool]))
597          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool]))
598  
599          # Connect components
600          pipeline.connect("llm.replies", "tool_invoker.messages")
601  
602          message = ChatMessage.from_user(text="Using tools, greet Vladimir")
603  
604          # Run pipeline
605          result = pipeline.run({"llm": {"messages": [message]}})
606  
607          # Check results
608          tool_messages = result["tool_invoker"]["tool_messages"]
609          assert len(tool_messages) == 1
610  
611          tool_message = tool_messages[0]
612          assert tool_message.is_from(ChatRole.TOOL)
613          assert "Vladimir" in tool_message.tool_call_result.result
614          assert not tool_message.tool_call_result.error
615  
616      @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
617      @pytest.mark.integration
618      @pytest.mark.flaky(reruns=3, reruns_delay=10)
619      def test_component_tool_in_pipeline_openai_tools_strict(self):
620          # Create component and convert it to tool
621          tool = ComponentTool(
622              component=SimpleComponent(),
623              name="hello_tool",
624              description="A tool that generates a greeting message for the user",
625          )
626  
627          # Create pipeline with OpenAIChatGenerator and ToolInvoker
628          pipeline = Pipeline()
629          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool], tools_strict=True))
630          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool]))
631  
632          # Connect components
633          pipeline.connect("llm.replies", "tool_invoker.messages")
634  
635          message = ChatMessage.from_user(text="Using tools, greet Vladimir")
636  
637          # Run pipeline
638          result = pipeline.run({"llm": {"messages": [message]}})
639  
640          # Check results
641          tool_messages = result["tool_invoker"]["tool_messages"]
642          assert len(tool_messages) == 1
643  
644          tool_message = tool_messages[0]
645          assert tool_message.is_from(ChatRole.TOOL)
646          assert "Vladimir" in tool_message.tool_call_result.result
647          assert not tool_message.tool_call_result.error
648  
649      @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
650      @pytest.mark.integration
651      def test_user_greeter_in_pipeline(self):
652          tool = ComponentTool(
653              component=UserGreeter(), name="user_greeter", description="A tool that greets users with their name and age"
654          )
655  
656          pipeline = Pipeline()
657          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool]))
658          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool]))
659          pipeline.connect("llm.replies", "tool_invoker.messages")
660  
661          message = ChatMessage.from_user(text="Greet the user Alice who is 30 years old")
662  
663          result = pipeline.run({"llm": {"messages": [message]}})
664          tool_messages = result["tool_invoker"]["tool_messages"]
665          assert len(tool_messages) == 1
666  
667          tool_message = tool_messages[0]
668          assert tool_message.is_from(ChatRole.TOOL)
669          assert tool_message.tool_call_result.result == str({"message": "User Alice is 30 years old"})
670          assert not tool_message.tool_call_result.error
671  
672      @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
673      @pytest.mark.integration
674      def test_list_processor_in_pipeline(self):
675          tool = ComponentTool(
676              component=ListProcessor(), name="list_processor", description="A tool that concatenates a list of strings"
677          )
678  
679          pipeline = Pipeline()
680          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool]))
681          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool]))
682          pipeline.connect("llm.replies", "tool_invoker.messages")
683  
684          # Be explicit about using tools, otherwise model will ignore the tool call and return the result directly.
685          message = ChatMessage.from_user(text="Using tools, join these words: hello, beautiful, world")
686  
687          result = pipeline.run({"llm": {"messages": [message]}})
688          tool_messages = result["tool_invoker"]["tool_messages"]
689          assert len(tool_messages) == 1
690  
691          tool_message = tool_messages[0]
692          assert tool_message.is_from(ChatRole.TOOL)
693          # Check that the result contains the expected words (handle whitespace variations)
694          result_str = tool_message.tool_call_result.result
695          assert "concatenated" in result_str
696          # Normalize whitespace in the result string and check it contains the expected words
697          normalized_result = " ".join(result_str.split())
698          assert "hello" in normalized_result
699          assert "beautiful" in normalized_result
700          assert "world" in normalized_result
701          assert not tool_message.tool_call_result.error
702  
703      @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
704      @pytest.mark.integration
705      def test_person_processor_in_pipeline(self):
706          tool = ComponentTool(
707              component=PersonProcessor(),
708              name="person_processor",
709              description="A tool that processes information about a person and their address",
710          )
711  
712          pipeline = Pipeline()
713          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool]))
714          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool]))
715          pipeline.connect("llm.replies", "tool_invoker.messages")
716  
717          message = ChatMessage.from_user(
718              text="Process information about the person Diana who lives at 123 Elm Street in Metropolis"
719          )
720  
721          result = pipeline.run({"llm": {"messages": [message]}})
722          tool_messages = result["tool_invoker"]["tool_messages"]
723          assert len(tool_messages) == 1
724  
725          tool_message = tool_messages[0]
726          assert tool_message.is_from(ChatRole.TOOL)
727          assert "Diana" in tool_message.tool_call_result.result and "Metropolis" in tool_message.tool_call_result.result
728          assert not tool_message.tool_call_result.error
729  
730      @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
731      @pytest.mark.integration
732      def test_document_processor_in_pipeline(self):
733          tool = ComponentTool(
734              component=DocumentProcessor(),
735              name="document_processor",
736              description="A tool that concatenates the content of multiple documents",
737          )
738  
739          pipeline = Pipeline()
740          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool]))
741          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool], convert_result_to_json_string=True))
742          pipeline.connect("llm.replies", "tool_invoker.messages")
743  
744          message = ChatMessage.from_user(
745              text="Concatenate these documents: First one says 'Hello world' and second one says 'Goodbye world' and "
746              "third one says 'Hello again', but use top_k=2. Set only content field of the document only. Do "
747              "not set id, meta, score, embedding, sparse_embedding, dataframe, blob fields."
748          )
749  
750          result = pipeline.run({"llm": {"messages": [message]}})
751  
752          tool_messages = result["tool_invoker"]["tool_messages"]
753          assert len(tool_messages) == 1
754  
755          tool_message = tool_messages[0]
756          assert tool_message.is_from(ChatRole.TOOL)
757          result = json.loads(tool_message.tool_call_result.result)
758          assert "concatenated" in result
759          assert "Hello world" in result["concatenated"]
760          assert "Goodbye world" in result["concatenated"]
761          assert not tool_message.tool_call_result.error
762  
763      @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
764      @pytest.mark.integration
765      def test_lost_in_middle_ranker_in_pipeline(self):
766          from haystack.components.rankers import LostInTheMiddleRanker
767  
768          tool = ComponentTool(
769              component=LostInTheMiddleRanker(),
770              name="lost_in_middle_ranker",
771              description="A tool that ranks documents using the Lost in the Middle algorithm and returns top k results",
772          )
773  
774          pipeline = Pipeline()
775          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool]))
776          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool]))
777          pipeline.connect("llm.replies", "tool_invoker.messages")
778  
779          message = ChatMessage.from_user(
780              text="I have three documents with content: 'First doc', 'Middle doc', and 'Last doc'. Rank them top_k=2. "
781              "Set only content field of the document only. Do not set id, meta, score, embedding, "
782              "sparse_embedding, dataframe, blob fields."
783          )
784  
785          result = pipeline.run({"llm": {"messages": [message]}})
786  
787          tool_messages = result["tool_invoker"]["tool_messages"]
788          assert len(tool_messages) == 1
789          tool_message = tool_messages[0]
790          assert tool_message.is_from(ChatRole.TOOL)
791  
792      @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
793      @pytest.mark.skipif(not os.environ.get("SERPERDEV_API_KEY"), reason="SERPERDEV_API_KEY not set")
794      @pytest.mark.integration
795      def test_serper_dev_web_search_in_pipeline(self):
796          tool = ComponentTool(
797              component=SerperDevWebSearch(api_key=Secret.from_env_var("SERPERDEV_API_KEY"), top_k=3),
798              name="web_search",
799              description="Search the web for current information on any topic",
800          )
801  
802          pipeline = Pipeline()
803          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool]))
804          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool]))
805          pipeline.connect("llm.replies", "tool_invoker.messages")
806  
807          result = pipeline.run(
808              {
809                  "llm": {
810                      "messages": [
811                          ChatMessage.from_user(text="Use the web search tool to find information about Nikola Tesla")
812                      ]
813                  }
814              }
815          )
816  
817          assert len(result["tool_invoker"]["tool_messages"]) == 1
818          tool_message = result["tool_invoker"]["tool_messages"][0]
819          assert tool_message.is_from(ChatRole.TOOL)
820          assert "Nikola Tesla" in tool_message.tool_call_result.result
821          assert not tool_message.tool_call_result.error
822  
823      def test_serde_in_pipeline(self, monkeypatch):
824          monkeypatch.setenv("SERPERDEV_API_KEY", "test-key")
825          monkeypatch.setenv("OPENAI_API_KEY", "test-key")
826  
827          # Create the search component and tool
828          search = SerperDevWebSearch(top_k=3)
829          tool = ComponentTool(component=search, name="web_search", description="Search the web for current information")
830  
831          # Create and configure the pipeline
832          pipeline = Pipeline()
833          pipeline.add_component("tool_invoker", ToolInvoker(tools=[tool]))
834          pipeline.add_component("llm", OpenAIChatGenerator(tools=[tool]))
835          pipeline.connect("tool_invoker.tool_messages", "llm.messages")
836  
837          # Serialize to dict and verify structure
838          pipeline_dict = pipeline.to_dict()
839          assert (
840              pipeline_dict["components"]["tool_invoker"]["type"] == "haystack.components.tools.tool_invoker.ToolInvoker"
841          )
842          assert len(pipeline_dict["components"]["tool_invoker"]["init_parameters"]["tools"]) == 1
843  
844          tool_dict = pipeline_dict["components"]["tool_invoker"]["init_parameters"]["tools"][0]
845          assert tool_dict["type"] == "haystack.tools.component_tool.ComponentTool"
846          assert tool_dict["data"]["name"] == "web_search"
847          assert tool_dict["data"]["component"]["type"] == "haystack.components.websearch.serper_dev.SerperDevWebSearch"
848          assert tool_dict["data"]["component"]["init_parameters"]["top_k"] == 3
849          assert tool_dict["data"]["component"]["init_parameters"]["api_key"]["type"] == "env_var"
850  
851          # Test round-trip serialization
852          pipeline_yaml = pipeline.dumps()
853          new_pipeline = Pipeline.loads(pipeline_yaml)
854          assert new_pipeline == pipeline
855  
856      def test_component_tool_serde(self):
857          tool = ComponentTool(
858              component=SimpleComponent(),
859              name="simple_tool",
860              description="A simple tool",
861              outputs_to_string={"source": "reply", "handler": reply_formatter},
862              inputs_from_state={"test": "text"},
863              outputs_to_state={"output": {"source": "reply", "handler": output_handler}},
864          )
865  
866          # Test serialization
867          expected_tool_dict = {
868              "type": "haystack.tools.component_tool.ComponentTool",
869              "data": {
870                  "component": {"type": "test_component_tool.SimpleComponent", "init_parameters": {}},
871                  "name": "simple_tool",
872                  "description": "A simple tool",
873                  "parameters": None,
874                  "outputs_to_string": {"source": "reply", "handler": "test_component_tool.reply_formatter"},
875                  "inputs_from_state": {"test": "text"},
876                  "outputs_to_state": {"output": {"source": "reply", "handler": "test_component_tool.output_handler"}},
877              },
878          }
879          tool_dict = tool.to_dict()
880          assert tool_dict == expected_tool_dict
881  
882          # Test deserialization
883          new_tool = ComponentTool.from_dict(expected_tool_dict)
884          assert new_tool.name == tool.name
885          assert new_tool.description == tool.description
886          assert new_tool.parameters == tool.parameters
887          assert new_tool.outputs_to_string == tool.outputs_to_string
888          assert new_tool.inputs_from_state == tool.inputs_from_state
889          assert new_tool.outputs_to_state == tool.outputs_to_state
890          assert isinstance(new_tool._component, SimpleComponent)
891  
892      def test_pipeline_component_fails(self):
893          comp = SimpleComponent()
894  
895          # Create a pipeline and add the component to it
896          pipeline = Pipeline()
897          pipeline.add_component("simple", comp)
898  
899          # Try to create a tool from the component and it should fail because the component has been added to a pipeline
900          # and thus can't be used as tool
901          with pytest.raises(ValueError, match="Component has been added to a pipeline"):
902              ComponentTool(component=comp)
903  
904      def test_deepcopy_with_jinja_based_component(self):
905          builder = PromptBuilder("{{query}}")
906          tool = ComponentTool(component=builder)
907          result = tool.function(query="Hello")
908          tool_copy = _deepcopy_with_exceptions(tool)
909          result_from_copy = tool_copy.function(query="Hello")
910          assert "prompt" in result_from_copy
911          assert result_from_copy["prompt"] == result["prompt"]
912  
913      def test_jinja_based_component_tool_in_pipeline(self, monkeypatch):
914          monkeypatch.setenv("OPENAI_API_KEY", "test-key")
915  
916          with patch("openai.resources.chat.completions.Completions.create") as mock_create:
917              mock_create.return_value = ChatCompletion(
918                  id="test",
919                  model="gpt-4o-mini",
920                  object="chat.completion",
921                  choices=[
922                      Choice(
923                          finish_reason="length",
924                          index=0,
925                          message=ChatCompletionMessage(role="assistant", content="A response from the model"),
926                      )
927                  ],
928                  created=1234567890,
929              )
930  
931              tool = ComponentTool(component=PromptBuilder("{{query}}"))
932              pipeline = Pipeline()
933              pipeline.add_component("llm", OpenAIChatGenerator())
934              result = pipeline.run({"llm": {"messages": [ChatMessage.from_user(text="Hello")], "tools": [tool]}})
935  
936          assert result["llm"]["replies"][0].text == "A response from the model"