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"