Files
deepset-ai--haystack/test/components/agents/test_agent.py
T
wehub-resource-sync c56bef871b
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

2542 lines
120 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import logging
import os
import re
from collections.abc import Iterator
from datetime import datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from jinja2 import TemplateSyntaxError
from openai import Stream
from openai.types.chat import ChatCompletionChunk, chat_completion_chunk
from haystack import Document, Pipeline, component, tracing
from haystack.components.agents.agent import Agent, _accumulate_usage, _select_tools_by_name
from haystack.components.agents.state import merge_lists, replace_values
from haystack.components.agents.tool_calling import _run_tool
from haystack.components.builders.chat_prompt_builder import ChatPromptBuilder
from haystack.components.builders.prompt_builder import PromptBuilder
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.joiners.branch import BranchJoiner
from haystack.components.joiners.list_joiner import ListJoiner
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.routers.conditional_router import ConditionalRouter
from haystack.core.component.types import OutputSocket
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.dataclasses.chat_message import ChatRole, TextContent
from haystack.dataclasses.streaming_chunk import StreamingChunk
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.tools import ComponentTool, Tool, tool
from haystack.tools.toolset import Toolset
from haystack.tracing.logging_tracer import LoggingTracer
from haystack.utils import Secret, serialize_callable
def _user_msg(text: str) -> str:
return f'{{% message role="user" %}}{text}{{% endmessage %}}'
def _sys_msg(text: str) -> str:
return f'{{% message role="system" %}}{text}{{% endmessage %}}'
def _assistant_with_usage(text: str | None = None, *, tool_calls=None, usage: dict[str, Any] | None = None):
"""Build an assistant ChatMessage with optional tool_calls and `meta['usage']` populated."""
meta: dict[str, Any] = {}
if usage is not None:
meta["usage"] = usage
if tool_calls is not None:
return ChatMessage.from_assistant(tool_calls=tool_calls, meta=meta or None)
return ChatMessage.from_assistant(text or "", meta=meta or None)
def sync_streaming_callback(chunk: StreamingChunk) -> None:
"""A synchronous streaming callback."""
pass
async def async_streaming_callback(chunk: StreamingChunk) -> None:
"""An asynchronous streaming callback."""
pass
def weather_function(location):
weather_info = {
"berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"},
"paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"},
"rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"},
}
for city, result in weather_info.items():
if city in location.lower():
return result
return {"weather": "unknown", "temperature": 0, "unit": "celsius"}
@tool
def weather_tool_with_decorator(location: str) -> str:
"""Provides weather information for a given location."""
return f"Weather report for {location}: 20°C, sunny"
@pytest.fixture
def weather_tool():
return Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters={"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]},
function=weather_function,
)
@pytest.fixture
def component_tool():
return ComponentTool(name="parrot", description="This is a parrot.", component=PromptBuilder(template="{{parrot}}"))
@pytest.fixture
def make_agent(weather_tool):
def _factory(**kwargs):
return Agent(chat_generator=MockChatGenerator(), tools=[weather_tool], **kwargs)
return _factory
class OpenAIMockStream(Stream[ChatCompletionChunk]):
def __init__(self, mock_chunk: ChatCompletionChunk, client=None, *args, **kwargs):
client = client or MagicMock()
super().__init__(client=client, *args, **kwargs) # noqa: B026
self.mock_chunk = mock_chunk
def __stream__(self) -> Iterator[ChatCompletionChunk]:
yield self.mock_chunk
@pytest.fixture
def openai_mock_chat_completion_chunk():
"""
Mock the OpenAI API completion chunk response and reuse it for tests
"""
with patch("openai.resources.chat.completions.Completions.create") as mock_chat_completion_create:
completion = ChatCompletionChunk(
id="foo",
model="gpt-4",
object="chat.completion.chunk",
choices=[
chat_completion_chunk.Choice(
finish_reason="stop",
logprobs=None,
index=0,
delta=chat_completion_chunk.ChoiceDelta(content="Hello", role="assistant"),
)
],
created=int(datetime.now().timestamp()),
usage=None,
)
mock_chat_completion_create.return_value = OpenAIMockStream(
completion, cast_to=None, response=None, client=None
)
yield mock_chat_completion_create
@component
class MockChatGeneratorWithoutTools:
"""A mock chat generator that implements ChatGenerator protocol but doesn't support tools."""
def to_dict(self) -> dict[str, Any]:
return {"type": "MockChatGeneratorWithoutTools", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGeneratorWithoutTools":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage]) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Hello")]}
@component
class MockChatGeneratorWithoutRunAsync:
"""A mock chat generator that implements ChatGenerator protocol but doesn't have run_async method."""
def to_dict(self) -> dict[str, Any]:
return {"type": "MockChatGeneratorWithoutRunAsync", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGeneratorWithoutRunAsync":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Hello")]}
@component
class MockChatGenerator:
def to_dict(self) -> dict[str, Any]:
return {"type": "MockChatGeneratorWithoutRunAsync", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGenerator":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Hello")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Hello from run_async")]}
@component
class ParallelToolCallingChatGenerator:
"""Requests two `weather_tool` calls on the first turn, then returns a plain reply so the agent loop exits."""
tool_invoked = False
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
if self.tool_invoked:
return {"replies": [ChatMessage.from_assistant("done")]}
self.tool_invoked = True
return {
"replies": [
ChatMessage.from_assistant(
tool_calls=[
ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
ToolCall(tool_name="weather_tool", arguments={"location": "Paris"}),
]
)
]
}
class TestAgent:
def test_output_types(self, weather_tool, component_tool, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
chat_generator = OpenAIChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool, component_tool])
assert agent.__haystack_output__._sockets_dict == {
"messages": OutputSocket(name="messages", type=list[ChatMessage], receivers=[]),
"last_message": OutputSocket(name="last_message", type=ChatMessage, receivers=[]),
"step_count": OutputSocket(name="step_count", type=int, receivers=[]),
"token_usage": OutputSocket(name="token_usage", type=dict[str, Any], receivers=[]),
"tool_call_counts": OutputSocket(name="tool_call_counts", type=dict[str, int], receivers=[]),
}
# Check that the internal-state keys are not set up as input sockets
assert {"step_count", "token_usage", "tool_call_counts"}.isdisjoint(
agent.__haystack_input__._sockets_dict.keys()
)
def test_to_dict(self, weather_tool, component_tool, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
generator = OpenAIChatGenerator()
agent = Agent(
chat_generator=generator,
tools=[weather_tool, component_tool],
exit_conditions=["text", "weather_tool"],
state_schema={"foo": {"type": str}},
tool_concurrency_limit=5,
tool_streaming_callback_passthrough=True,
)
serialized_agent = agent.to_dict()
# Verify the model is truthy and serialized
assert "model" in serialized_agent["init_parameters"]["chat_generator"]["init_parameters"]
model_name = serialized_agent["init_parameters"]["chat_generator"]["init_parameters"]["model"]
# Check the rest of the structure
expected_structure = {
"type": "haystack.components.agents.agent.Agent",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": model_name,
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"name": "weather_tool",
"description": "Provides weather information for a given location.",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
"function": "test_agent.weather_function",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
},
{
"type": "haystack.tools.component_tool.ComponentTool",
"data": {
"component": {
"type": "haystack.components.builders.prompt_builder.PromptBuilder",
"init_parameters": {
"template": "{{parrot}}",
"variables": None,
"required_variables": "*",
},
},
"name": "parrot",
"description": "This is a parrot.",
"parameters": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
},
],
"system_prompt": None,
"user_prompt": None,
"required_variables": None,
"exit_conditions": ["text", "weather_tool"],
"state_schema": {"foo": {"type": "str"}},
"max_agent_steps": 100,
"streaming_callback": None,
"raise_on_tool_invocation_failure": False,
"tool_concurrency_limit": 5,
"tool_streaming_callback_passthrough": True,
"hooks": None,
},
}
assert serialized_agent == expected_structure
def test_to_dict_with_toolset(self, monkeypatch, weather_tool):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
toolset = Toolset(tools=[weather_tool])
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=toolset)
serialized_agent = agent.to_dict()
# Verify the model is truthy and serialized
assert "model" in serialized_agent["init_parameters"]["chat_generator"]["init_parameters"]
model_name = serialized_agent["init_parameters"]["chat_generator"]["init_parameters"]["model"]
# Check the rest of the structure
expected_structure = {
"type": "haystack.components.agents.agent.Agent",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": model_name,
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"tools": {
"type": "haystack.tools.toolset.Toolset",
"data": {
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"name": "weather_tool",
"description": "Provides weather information for a given location.",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
"function": "test_agent.weather_function",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
}
]
},
},
"system_prompt": None,
"user_prompt": None,
"required_variables": None,
"exit_conditions": ["text"],
"state_schema": {},
"max_agent_steps": 100,
"raise_on_tool_invocation_failure": False,
"streaming_callback": None,
"tool_concurrency_limit": 4,
"tool_streaming_callback_passthrough": False,
"hooks": None,
},
}
assert serialized_agent == expected_structure
def test_agent_serialization_with_tool_decorator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool_with_decorator])
serialized_agent = agent.to_dict()
deserialized_agent = Agent.from_dict(serialized_agent)
assert deserialized_agent.tools == agent.tools
assert isinstance(deserialized_agent.chat_generator, OpenAIChatGenerator)
# Model name should match whatever the default is - not testing specific model
assert deserialized_agent.chat_generator.model == agent.chat_generator.model
assert deserialized_agent.chat_generator.api_key == Secret.from_env_var("OPENAI_API_KEY")
assert deserialized_agent.exit_conditions == ["text"]
def test_from_dict(self, monkeypatch):
model = "gpt-5"
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
data = {
"type": "haystack.components.agents.agent.Agent",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": model,
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"name": "weather_tool",
"description": "Provides weather information for a given location.",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
"function": "test_agent.weather_function",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
},
{
"type": "haystack.tools.component_tool.ComponentTool",
"data": {
"component": {
"type": "haystack.components.builders.prompt_builder.PromptBuilder",
"init_parameters": {
"template": "{{parrot}}",
"variables": None,
"required_variables": "*",
},
},
"name": "parrot",
"description": "This is a parrot.",
"parameters": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
},
],
"system_prompt": None,
"exit_conditions": ["text", "weather_tool"],
"state_schema": {"foo": {"type": "str"}},
"max_agent_steps": 100,
"raise_on_tool_invocation_failure": False,
"streaming_callback": None,
"tool_concurrency_limit": 5,
"tool_streaming_callback_passthrough": True,
},
}
agent = Agent.from_dict(data)
assert isinstance(agent, Agent)
assert isinstance(agent.chat_generator, OpenAIChatGenerator)
# from_dict should restore the model from the dict (testing backward compatibility)
assert agent.chat_generator.model == model
assert agent.chat_generator.api_key == Secret.from_env_var("OPENAI_API_KEY")
assert agent.tools[0].function is weather_function
assert isinstance(agent.tools[1]._component, PromptBuilder)
assert agent.exit_conditions == ["text", "weather_tool"]
assert agent.state_schema == {
"foo": {"type": str},
"messages": {"handler": merge_lists, "type": list[ChatMessage]},
"step_count": {"type": int, "handler": replace_values},
"token_usage": {"type": dict[str, Any], "handler": replace_values},
"tool_call_counts": {"type": dict[str, int], "handler": replace_values},
"continue_run": {"type": bool, "handler": replace_values},
"tools": {"type": list, "handler": replace_values},
"hook_context": {"type": dict[str, Any], "handler": replace_values},
}
assert agent.tool_concurrency_limit == 5
assert agent.tool_streaming_callback_passthrough is True
def test_from_dict_with_toolset(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
data = {
"type": "haystack.components.agents.agent.Agent",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": "gpt-4o-mini",
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"tools": {
"type": "haystack.tools.toolset.Toolset",
"data": {
"tools": [
{
"type": "haystack.tools.tool.Tool",
"data": {
"name": "weather_tool",
"description": "Provides weather information for a given location.",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
"function": "test_agent.weather_function",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
}
]
},
},
"system_prompt": None,
"exit_conditions": ["text"],
"state_schema": {},
"max_agent_steps": 100,
"raise_on_tool_invocation_failure": False,
"streaming_callback": None,
"tool_concurrency_limit": 1,
"tool_streaming_callback_passthrough": False,
},
}
agent = Agent.from_dict(data)
assert isinstance(agent, Agent)
assert isinstance(agent.chat_generator, OpenAIChatGenerator)
# from_dict should restore the model from the dict (testing backward compatibility)
assert agent.chat_generator.model == "gpt-4o-mini"
assert agent.chat_generator.api_key == Secret.from_env_var("OPENAI_API_KEY")
assert isinstance(agent.tools, Toolset)
assert agent.tools[0].function is weather_function
assert agent.exit_conditions == ["text"]
def test_from_dict_state_schema_none(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
data = {
"type": "haystack.components.agents.agent.Agent",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": "gpt-4o-mini",
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"tools": None,
"system_prompt": None,
"exit_conditions": ["text"],
"state_schema": None,
"max_agent_steps": 100,
"raise_on_tool_invocation_failure": False,
"streaming_callback": None,
"tool_concurrency_limit": 4,
"tool_streaming_callback_passthrough": False,
},
}
agent = Agent.from_dict(data)
assert agent.state_schema == {
"messages": {"type": list[ChatMessage], "handler": merge_lists},
"step_count": {"type": int, "handler": replace_values},
"token_usage": {"type": dict[str, Any], "handler": replace_values},
"tool_call_counts": {"type": dict[str, int], "handler": replace_values},
"continue_run": {"type": bool, "handler": replace_values},
"tools": {"type": list, "handler": replace_values},
"hook_context": {"type": dict[str, Any], "handler": replace_values},
}
def test_serde(self, weather_tool, component_tool, monkeypatch):
monkeypatch.setenv("FAKE_OPENAI_KEY", "fake-key")
generator = OpenAIChatGenerator(api_key=Secret.from_env_var("FAKE_OPENAI_KEY"))
agent = Agent(
chat_generator=generator,
tools=[weather_tool, component_tool],
exit_conditions=["text", "weather_tool"],
state_schema={"foo": {"type": str}},
streaming_callback=sync_streaming_callback,
)
serialized_agent = agent.to_dict()
init_parameters = serialized_agent["init_parameters"]
assert serialized_agent["type"] == "haystack.components.agents.agent.Agent"
assert (
init_parameters["chat_generator"]["type"]
== "haystack.components.generators.chat.openai.OpenAIChatGenerator"
)
assert init_parameters["streaming_callback"] == "test_agent.sync_streaming_callback"
assert init_parameters["tools"][0]["data"]["function"] == serialize_callable(weather_function)
assert (
init_parameters["tools"][1]["data"]["component"]["type"]
== "haystack.components.builders.prompt_builder.PromptBuilder"
)
assert init_parameters["exit_conditions"] == ["text", "weather_tool"]
deserialized_agent = Agent.from_dict(serialized_agent)
assert isinstance(deserialized_agent, Agent)
assert isinstance(deserialized_agent.chat_generator, OpenAIChatGenerator)
assert deserialized_agent.tools[0].function is weather_function
assert isinstance(deserialized_agent.tools[1]._component, PromptBuilder)
assert deserialized_agent.exit_conditions == ["text", "weather_tool"]
assert deserialized_agent.state_schema == {
"foo": {"type": str},
"messages": {"handler": merge_lists, "type": list[ChatMessage]},
"step_count": {"type": int, "handler": replace_values},
"token_usage": {"type": dict[str, Any], "handler": replace_values},
"tool_call_counts": {"type": dict[str, int], "handler": replace_values},
"continue_run": {"type": bool, "handler": replace_values},
"tools": {"type": list, "handler": replace_values},
"hook_context": {"type": dict[str, Any], "handler": replace_values},
}
assert deserialized_agent.streaming_callback is sync_streaming_callback
def test_exit_conditions(self, weather_tool, component_tool, monkeypatch):
monkeypatch.setenv("FAKE_OPENAI_KEY", "fake-key")
generator = OpenAIChatGenerator(api_key=Secret.from_env_var("FAKE_OPENAI_KEY"))
# Default exit condition
agent = Agent(chat_generator=generator, tools=[weather_tool, component_tool])
assert agent.exit_conditions == ["text"]
# Multiple exit conditions are stored as-is
agent = Agent(
chat_generator=generator, tools=[weather_tool, component_tool], exit_conditions=["text", "weather_tool"]
)
assert agent.exit_conditions == ["text", "weather_tool"]
# Exit conditions are no longer validated against tool names at init: tool sets can be dynamic
# (e.g. SearchableToolset/MCPToolset) or provided at runtime, so unknown names pass through.
agent = Agent(chat_generator=generator, tools=[weather_tool], exit_conditions=["not_loaded_yet"])
assert agent.exit_conditions == ["not_loaded_yet"]
def test_tool_concurrency_limit_validation(self, weather_tool, monkeypatch):
monkeypatch.setenv("FAKE_OPENAI_KEY", "fake-key")
generator = OpenAIChatGenerator(api_key=Secret.from_env_var("FAKE_OPENAI_KEY"))
with pytest.raises(ValueError, match="tool_concurrency_limit must be greater than or equal to 1"):
Agent(chat_generator=generator, tools=[weather_tool], tool_concurrency_limit=0)
def test_run_with_params_streaming(self, openai_mock_chat_completion_chunk, weather_tool):
chat_generator = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
streaming_callback_called = False
def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
agent = Agent(chat_generator=chat_generator, streaming_callback=streaming_callback, tools=[weather_tool])
response = agent.run([ChatMessage.from_user("Hello")])
# check we called the streaming callback
assert streaming_callback_called is True
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "messages" in response
assert isinstance(response["messages"], list)
assert len(response["messages"]) == 2
assert [isinstance(reply, ChatMessage) for reply in response["messages"]]
assert "Hello" in response["messages"][1].text # see openai_mock_chat_completion_chunk
assert "last_message" in response
assert isinstance(response["last_message"], ChatMessage)
def test_run_with_run_streaming(self, openai_mock_chat_completion_chunk, weather_tool):
chat_generator = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
streaming_callback_called = False
def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
response = agent.run([ChatMessage.from_user("Hello")], streaming_callback=streaming_callback)
# check we called the streaming callback
assert streaming_callback_called is True
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "messages" in response
assert isinstance(response["messages"], list)
assert len(response["messages"]) == 2
assert [isinstance(reply, ChatMessage) for reply in response["messages"]]
assert "Hello" in response["messages"][1].text # see openai_mock_chat_completion_chunk
assert "last_message" in response
assert isinstance(response["last_message"], ChatMessage)
def test_keep_generator_streaming(self, openai_mock_chat_completion_chunk, weather_tool):
streaming_callback_called = False
def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
chat_generator = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"), streaming_callback=streaming_callback
)
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
response = agent.run([ChatMessage.from_user("Hello")])
# check we called the streaming callback
assert streaming_callback_called is True
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "messages" in response
assert isinstance(response["messages"], list)
assert len(response["messages"]) == 2
assert [isinstance(reply, ChatMessage) for reply in response["messages"]]
assert "Hello" in response["messages"][1].text # see openai_mock_chat_completion_chunk
assert "last_message" in response
assert isinstance(response["last_message"], ChatMessage)
def test_chat_generator_must_support_tools(self, weather_tool):
chat_generator = MockChatGeneratorWithoutTools()
with pytest.raises(TypeError, match="MockChatGeneratorWithoutTools does not accept tools"):
Agent(chat_generator=chat_generator, tools=[weather_tool])
def test_no_tools_with_chat_generator_without_tools_support(self):
chat_generator = MockChatGeneratorWithoutTools()
agent = Agent(chat_generator=chat_generator, max_agent_steps=1)
response = agent.run(messages=[ChatMessage.from_user("Hello")])
assert isinstance(response, dict)
assert "messages" in response
assert len(response["messages"]) == 2
assert response["messages"][0].text == "Hello"
assert response["messages"][1].text == "Hello"
assert response["last_message"] == response["messages"][-1]
def test_exceed_max_steps(self, monkeypatch, weather_tool, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
generator = OpenAIChatGenerator()
mock_messages = [
ChatMessage.from_assistant("First response"),
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
),
]
agent = Agent(chat_generator=generator, tools=[weather_tool], max_agent_steps=0)
# Patch agent.chat_generator.run to return mock_messages
agent.chat_generator.run = MagicMock(return_value={"replies": mock_messages})
with caplog.at_level(logging.WARNING):
result = agent.run([ChatMessage.from_user("Hello")])
assert "Agent reached maximum agent steps" in caplog.text
assert result["step_count"] == 0
def test_exit_condition_exits(self, monkeypatch, weather_tool):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
generator = OpenAIChatGenerator()
# Mock messages where the exit condition appears in the second message
mock_messages = [
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
)
]
agent = Agent(chat_generator=generator, tools=[weather_tool], exit_conditions=["weather_tool"])
# Patch agent.chat_generator.run to return mock_messages
agent.chat_generator.run = MagicMock(return_value={"replies": mock_messages})
result = agent.run([ChatMessage.from_user("Hello")])
assert "messages" in result
assert len(result["messages"]) == 3
assert result["messages"][-2].tool_call.tool_name == "weather_tool"
assert (
result["messages"][-1].tool_call_result.result
== '{"weather": "mostly sunny", "temperature": 7, "unit": "celsius"}'
)
assert "last_message" in result
assert isinstance(result["last_message"], ChatMessage)
assert result["messages"][-1] == result["last_message"]
def test_exit_condition_on_tool_provided_at_runtime(self, monkeypatch, weather_tool):
"""An exit condition naming a tool absent at init still triggers once that tool is provided at runtime."""
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
# weather_tool is NOT among the init tools, but it is named as an exit condition.
agent = Agent(
chat_generator=OpenAIChatGenerator(), tools=[], exit_conditions=["weather_tool"], max_agent_steps=5
)
# The model calls weather_tool, which is supplied only at runtime.
mock_messages = [
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
)
]
agent.chat_generator.run = MagicMock(return_value={"replies": mock_messages})
result = agent.run([ChatMessage.from_user("What's the weather in Berlin?")], tools=[weather_tool])
# The agent exits right after the exit-condition tool runs (single step), not at max_agent_steps.
assert result["step_count"] == 1
assert result["messages"][-2].tool_call.tool_name == "weather_tool"
assert (
result["messages"][-1].tool_call_result.result
== '{"weather": "mostly sunny", "temperature": 7, "unit": "celsius"}'
)
assert result["messages"][-1] == result["last_message"]
def test_does_not_exit_on_empty_assistant_message(self, monkeypatch, weather_tool):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool], exit_conditions=["text"])
# The first reply simulates the LLM producing an invalid tool call that our code discards,leaving an assistant
# message with empty text and no tool calls. This must not be treated as a "text" exit condition, so the agent
# keeps looping and recovers on the second reply.
empty_reply = {"replies": [ChatMessage.from_assistant(text="")]}
recovered_reply = {"replies": [ChatMessage.from_assistant(text="The weather is sunny.")]}
agent.chat_generator.run = MagicMock(side_effect=[empty_reply, recovered_reply])
result = agent.run([ChatMessage.from_user("What's the weather?")])
assert agent.chat_generator.run.call_count == 2
assert result["last_message"].text == "The weather is sunny."
def test_check_exit_conditions_parallel_tool_calls(self, monkeypatch, weather_tool):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool], exit_conditions=["weather_tool"])
finish_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
other_call = ToolCall(tool_name="search", arguments={"q": "weather Berlin"})
# Exit-condition call first
llm_first = [ChatMessage.from_assistant(tool_calls=[finish_call, other_call])]
# Exit-condition call second
llm_second = [ChatMessage.from_assistant(tool_calls=[other_call, finish_call])]
tool_messages_ok = [ChatMessage.from_tool(tool_result="ok", origin=finish_call, error=False)]
assert agent._check_exit_conditions(llm_first, tool_messages_ok) is True
assert agent._check_exit_conditions(llm_second, tool_messages_ok) is True
def test_check_exit_conditions_parallel_calls_with_errored_exit_tool(self, monkeypatch, weather_tool):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool], exit_conditions=["weather_tool"])
finish_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
other_call = ToolCall(tool_name="search", arguments={"q": "weather Berlin"})
llm_messages = [ChatMessage.from_assistant(tool_calls=[other_call, finish_call])]
tool_messages_errored = [ChatMessage.from_tool(tool_result="boom", origin=finish_call, error=True)]
assert agent._check_exit_conditions(llm_messages, tool_messages_errored) is False
def test_check_exit_conditions_parallel_calls_error_only_on_non_exit_tool(
self, monkeypatch, weather_tool, component_tool
):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
agent = Agent(
chat_generator=OpenAIChatGenerator(), tools=[weather_tool, component_tool], exit_conditions=["weather_tool"]
)
finish_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
other_call = ToolCall(tool_name="parrot", arguments={"parrot": "hi"})
llm_messages = [ChatMessage.from_assistant(tool_calls=[other_call, finish_call])]
tool_messages = [
ChatMessage.from_tool(tool_result="boom", origin=other_call, error=True),
ChatMessage.from_tool(tool_result="ok", origin=finish_call, error=False),
]
assert agent._check_exit_conditions(llm_messages, tool_messages) is True
def test_agent_with_no_tools(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
generator = OpenAIChatGenerator()
# Mock messages where the exit condition appears in the second message
mock_messages = [ChatMessage.from_assistant("Berlin")]
agent = Agent(chat_generator=generator, tools=[], max_agent_steps=3)
# Patch agent.chat_generator.run to return mock_messages
agent.chat_generator.run = MagicMock(return_value={"replies": mock_messages})
response = agent.run([ChatMessage.from_user("What is the capital of Germany?")])
assert isinstance(response, dict)
assert "messages" in response
assert isinstance(response["messages"], list)
assert len(response["messages"]) == 2
assert [isinstance(reply, ChatMessage) for reply in response["messages"]]
assert response["messages"][0].text == "What is the capital of Germany?"
assert response["messages"][1].text == "Berlin"
assert "last_message" in response
assert isinstance(response["last_message"], ChatMessage)
assert response["messages"][-1] == response["last_message"]
def test_run_with_system_prompt(self, weather_tool):
chat_generator = MockChatGeneratorWithoutRunAsync()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], system_prompt="This is a system prompt.")
response = agent.run([ChatMessage.from_user("What is the weather in Berlin?")])
assert response["messages"][0].text == "This is a system prompt."
def test_run_with_tools_run_param(self, weather_tool: Tool, component_tool: Tool, monkeypatch):
@component
class MockChatGenerator:
tool_invoked = False
@component.output_types(replies=list[ChatMessage])
def run(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
assert tools == [weather_tool]
tool_message = ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
)
message = tool_message if not self.tool_invoked else ChatMessage.from_assistant("Hello")
self.tool_invoked = True
return {"replies": [message]}
chat_generator = MockChatGenerator()
agent = Agent(
chat_generator=chat_generator,
tools=[component_tool],
system_prompt="This is a system prompt.",
tool_concurrency_limit=3,
tool_streaming_callback_passthrough=True,
)
with patch("haystack.components.agents.agent._run_tool", wraps=_run_tool) as run_tool_mock:
agent.run([ChatMessage.from_user("What is the weather in Berlin?")], tools=[weather_tool])
run_tool_mock.assert_called_once()
assert run_tool_mock.call_args.kwargs["tools"] == [weather_tool]
assert run_tool_mock.call_args.kwargs["max_workers"] == 3
assert run_tool_mock.call_args.kwargs["enable_streaming_callback_passthrough"] is True
def test_run_with_tools_run_param_for_tool_selection(self, weather_tool: Tool, component_tool: Tool, monkeypatch):
@component
class MockChatGenerator:
tool_invoked = False
@component.output_types(replies=list[ChatMessage])
def run(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
assert tools == [weather_tool]
tool_message = ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
)
message = tool_message if not self.tool_invoked else ChatMessage.from_assistant("Hello")
self.tool_invoked = True
return {"replies": [message]}
chat_generator = MockChatGenerator()
agent = Agent(
chat_generator=chat_generator,
tools=[weather_tool, component_tool],
system_prompt="This is a system prompt.",
)
with patch("haystack.components.agents.agent._run_tool", wraps=_run_tool) as run_tool_mock:
agent.run([ChatMessage.from_user("What is the weather in Berlin?")], tools=[weather_tool.name])
run_tool_mock.assert_called_once()
assert run_tool_mock.call_args.kwargs["tools"] == [weather_tool]
def test_run_no_messages(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
chat_generator = OpenAIChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[])
result = agent.run([])
assert result["messages"] == []
def test_run_only_system_prompt(self, caplog):
chat_generator = MockChatGeneratorWithoutRunAsync()
agent = Agent(chat_generator=chat_generator, tools=[], system_prompt="This is a system prompt.")
_ = agent.run([])
assert "All messages provided to the Agent component are system messages." in caplog.text
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
@pytest.mark.integration
def test_run(self, weather_tool):
chat_generator = OpenAIChatGenerator(model="gpt-4.1-nano")
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], max_agent_steps=3)
response = agent.run([ChatMessage.from_user("What is the weather in Berlin?")])
assert isinstance(response, dict)
assert "messages" in response
assert isinstance(response["messages"], list)
assert len(response["messages"]) == 4
assert [isinstance(reply, ChatMessage) for reply in response["messages"]]
# Loose check of message texts
assert response["messages"][0].text == "What is the weather in Berlin?"
assert response["messages"][1].text is None
assert response["messages"][2].text is None
assert response["messages"][3].text is not None
# Loose check of message metadata
assert response["messages"][0].meta == {}
assert response["messages"][1].meta.get("model") is not None
assert response["messages"][2].meta == {}
assert response["messages"][3].meta.get("model") is not None
# Loose check of tool calls and results
assert response["messages"][1].tool_calls[0].tool_name == "weather_tool"
assert response["messages"][1].tool_calls[0].arguments is not None
assert response["messages"][2].tool_call_results[0].result is not None
assert response["messages"][2].tool_call_results[0].origin is not None
assert "last_message" in response
assert isinstance(response["last_message"], ChatMessage)
assert response["messages"][-1] == response["last_message"]
# Auto-populated run outputs:
# 4 messages → tool call + final answer = 2 LLM calls = 2 steps; one weather_tool invocation.
assert response["step_count"] == 2
assert response["tool_call_counts"] == {"weather_tool": 1}
assert response["token_usage"]["prompt_tokens"] > 0
assert response["token_usage"]["completion_tokens"] > 0
assert response["token_usage"]["total_tokens"] > 0
@pytest.mark.asyncio
async def test_generation_kwargs(self):
chat_generator = MockChatGenerator()
agent = Agent(chat_generator=chat_generator)
chat_generator.run_async = AsyncMock(return_value={"replies": [ChatMessage.from_assistant("Hello")]})
await agent.run_async([ChatMessage.from_user("Hello")], generation_kwargs={"temperature": 0.0})
expected_messages = [
ChatMessage(_role=ChatRole.USER, _content=[TextContent(text="Hello")], _name=None, _meta={})
]
# No tools were configured, so the Agent does not pass a `tools` argument to the chat generator.
chat_generator.run_async.assert_called_once_with(
messages=expected_messages, generation_kwargs={"temperature": 0.0}
)
@pytest.mark.asyncio
async def test_run_async_uses_chat_generator_run_async_when_available(self, weather_tool):
chat_generator = MockChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
chat_generator.run_async = AsyncMock(
return_value={"replies": [ChatMessage.from_assistant("Hello from run_async")]}
)
result = await agent.run_async([ChatMessage.from_user("Hello")])
expected_messages = [
ChatMessage(_role=ChatRole.USER, _content=[TextContent(text="Hello")], _name=None, _meta={})
]
chat_generator.run_async.assert_called_once_with(messages=expected_messages, tools=[weather_tool])
assert isinstance(result, dict)
assert "messages" in result
assert isinstance(result["messages"], list)
assert len(result["messages"]) == 2
assert [isinstance(reply, ChatMessage) for reply in result["messages"]]
assert "Hello from run_async" in result["messages"][1].text
assert "last_message" in result
assert isinstance(result["last_message"], ChatMessage)
assert result["messages"][-1] == result["last_message"]
@pytest.mark.asyncio
async def test_run_async_falls_back_to_sync_run_for_sync_only_chat_generator(self, weather_tool):
"""`agent.run_async` must accept a chat generator that only implements `run` (no `run_async`).
The Agent should dispatch the sync call to the default executor rather than raising AttributeError."""
chat_generator = MockChatGeneratorWithoutRunAsync()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
assert not getattr(chat_generator, "__haystack_supports_async__", False)
run_mock = MagicMock(wraps=chat_generator.run)
chat_generator.run = run_mock
result = await agent.run_async([ChatMessage.from_user("Hello")])
run_mock.assert_called_once()
# MockChatGeneratorWithoutRunAsync.run returns ChatMessage.from_assistant("Hello")
assert result["messages"][1].text == "Hello"
assert result["last_message"] == result["messages"][-1]
@pytest.mark.integration
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
def test_agent_streaming_with_tool_call(self, weather_tool):
chat_generator = OpenAIChatGenerator(model="gpt-4.1-nano")
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
streaming_callback_called = False
def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
result = agent.run(
[ChatMessage.from_user("What's the weather in Paris?")],
streaming_callback=streaming_callback,
generation_kwargs={"stream_options": {"include_usage": True}},
)
assert result is not None
assert result["messages"] is not None
assert result["last_message"] is not None
assert streaming_callback_called
# Auto-populated run outputs.
assert result["step_count"] == 2
assert result["tool_call_counts"] == {"weather_tool": 1}
assert result["token_usage"]["prompt_tokens"] > 0
assert result["token_usage"]["completion_tokens"] > 0
assert result["token_usage"]["total_tokens"] > 0
@pytest.mark.asyncio
async def test_does_not_exit_on_empty_assistant_message_async(self, monkeypatch, weather_tool):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool], exit_conditions=["text"])
# The first reply simulates the LLM producing an invalid tool call that our code discards,leaving an assistant
# message with empty text and no tool calls. This must not be treated as a "text" exit condition, so the agent
# keeps looping and recovers on the second reply.
empty_reply = {"replies": [ChatMessage.from_assistant(text="")]}
recovered_reply = {"replies": [ChatMessage.from_assistant(text="The weather is sunny.")]}
agent.chat_generator.run_async = AsyncMock(side_effect=[empty_reply, recovered_reply])
result = await agent.run_async([ChatMessage.from_user("What's the weather?")])
assert agent.chat_generator.run_async.call_count == 2
assert result["last_message"].text == "The weather is sunny."
@pytest.mark.asyncio
async def test_run_async_with_async_streaming_callback(self, weather_tool):
chat_generator = MockChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], streaming_callback=async_streaming_callback)
# This should not raise any exception
result = await agent.run_async([ChatMessage.from_user("Hello")])
assert "messages" in result
assert len(result["messages"]) == 2
assert result["messages"][1].text == "Hello from run_async"
def test_run_with_async_streaming_callback_fails(self, weather_tool):
chat_generator = MockChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], streaming_callback=async_streaming_callback)
with pytest.raises(ValueError, match="The init callback cannot be a coroutine"):
agent.run([ChatMessage.from_user("Hello")])
@pytest.mark.asyncio
async def test_run_async_with_sync_streaming_callback_warns(self, weather_tool, caplog):
chat_generator = MockChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], streaming_callback=sync_streaming_callback)
with caplog.at_level(logging.WARNING):
result = await agent.run_async([ChatMessage.from_user("Hello")])
assert "sync streaming callback" in caplog.text
assert "messages" in result
assert len(result["messages"]) == 2
def test_reserved_state_schema_keys_raise(self, monkeypatch, weather_tool):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
for reserved in ("step_count", "token_usage", "tool_call_counts"):
with pytest.raises(ValueError, match="reserved for Agent internal state"):
Agent(
chat_generator=OpenAIChatGenerator(), tools=[weather_tool], state_schema={reserved: {"type": int}}
)
def test_run_populates_token_usage_and_tool_call_counts(self, monkeypatch, weather_tool, component_tool):
"""A multi-step run aggregates step_count, token_usage (incl. nested details), and tool_call_counts."""
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool, component_tool])
# Step 1: two parallel tool calls + usage with nested detail dicts.
# Step 2: one more weather_tool call + flat usage.
# Step 3: final text answer + usage.
first_step = [
_assistant_with_usage(
tool_calls=[
ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
ToolCall(tool_name="parrot", arguments={"parrot": "hi"}),
],
usage={
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"completion_tokens_details": {"reasoning_tokens": 2},
},
)
]
second_step = [
_assistant_with_usage(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Paris"})],
usage={"prompt_tokens": 6, "completion_tokens": 3, "total_tokens": 9},
)
]
third_step = [
_assistant_with_usage(
"Done.",
usage={
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6,
"completion_tokens_details": {"reasoning_tokens": 1},
},
)
]
agent.chat_generator.run = MagicMock(
side_effect=[{"replies": first_step}, {"replies": second_step}, {"replies": third_step}]
)
result = agent.run([ChatMessage.from_user("Hi")])
assert result["step_count"] == 3
assert result["tool_call_counts"] == {"weather_tool": 2, "parrot": 1}
assert result["token_usage"] == {
"prompt_tokens": 20,
"completion_tokens": 10,
"total_tokens": 30,
"completion_tokens_details": {"reasoning_tokens": 3},
}
@pytest.mark.asyncio
async def test_run_async_populates_token_usage_and_tool_call_counts(self, monkeypatch, weather_tool):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool])
first_step = [
_assistant_with_usage(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})],
usage={"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6},
)
]
second_step = [
_assistant_with_usage("Done.", usage={"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4})
]
agent.chat_generator.run_async = AsyncMock(side_effect=[{"replies": first_step}, {"replies": second_step}])
result = await agent.run_async([ChatMessage.from_user("Hi")])
assert result["step_count"] == 2
assert result["tool_call_counts"] == {"weather_tool": 1}
assert result["token_usage"] == {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}
def test_metadata_outputs_show_defaults_when_no_data(self, weather_tool):
"""`token_usage` stays empty and `tool_call_counts` reports zero for every tool when nothing happens."""
agent = Agent(chat_generator=MockChatGenerator(), tools=[weather_tool])
result = agent.run([ChatMessage.from_user("Hi")])
# MockChatGenerator returns a text-only reply with no `usage` meta and no tool calls.
assert result["step_count"] == 1
assert result["token_usage"] == {}
assert result["tool_call_counts"] == {"weather_tool": 0}
class TestAccumulateUsage:
"""Unit tests for the `_accumulate_usage` helper used to merge ChatGenerator usage dicts."""
def test_sums_flat_numeric_keys(self):
current = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
new = {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}
assert _accumulate_usage(current, new) == {"prompt_tokens": 13, "completion_tokens": 7, "total_tokens": 20}
def test_merges_nested_detail_dicts_recursively(self):
current = {"prompt_tokens": 10, "completion_tokens_details": {"reasoning_tokens": 2, "audio_tokens": 0}}
new = {
"prompt_tokens": 4,
"completion_tokens_details": {"reasoning_tokens": 3, "audio_tokens": 1},
"prompt_tokens_details": {"cached_tokens": 6},
}
assert _accumulate_usage(current, new) == {
"prompt_tokens": 14,
"completion_tokens_details": {"reasoning_tokens": 5, "audio_tokens": 1},
"prompt_tokens_details": {"cached_tokens": 6},
}
def test_adds_keys_missing_in_current(self):
assert _accumulate_usage({"prompt_tokens": 5}, {"completion_tokens": 7}) == {
"prompt_tokens": 5,
"completion_tokens": 7,
}
def test_empty_current_dict_returns_copy_of_new(self):
new = {"prompt_tokens": 5, "details": {"cached_tokens": 1}}
result = _accumulate_usage({}, new)
assert result == new
# Nested dicts must be deep-copied so future merges don't mutate the source.
new["details"]["cached_tokens"] = 999
assert result["details"]["cached_tokens"] == 1
def test_non_dict_non_numeric_falls_back_to_new(self):
# Strings, lists, or any other type that isn't a dict-or-number pair returns `new` unchanged.
assert _accumulate_usage("old-model", "new-model") == "new-model"
assert _accumulate_usage(5, "stringified") == "stringified"
assert _accumulate_usage({"model": "gpt-x"}, {"model": "gpt-y"}) == {"model": "gpt-y"}
def test_sums_floats(self):
assert _accumulate_usage(1.5, 2.25) == 3.75
class TestAgentTracing:
def test_agent_tracing_span_run(self, caplog, monkeypatch, weather_tool):
chat_generator = MockChatGeneratorWithoutRunAsync()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
tracing.tracer.is_content_tracing_enabled = True
tracing.enable_tracing(LoggingTracer())
caplog.set_level(logging.DEBUG)
_ = agent.run([ChatMessage.from_user("What's the weather in Paris?")])
# LoggingTracer emits one "Operation: <name>" record when a span exits, immediately followed by that
# span's tag records. We walk the log stream and bucket tags under the operation that owns them so we
# can assert the agent's nested span hierarchy, not just a flat list of tags.
spans: list[tuple[str, dict[str, Any]]] = []
for record in caplog.records:
if hasattr(record, "operation_name"):
spans.append((record.operation_name, {}))
elif hasattr(record, "tag_name") and spans:
spans[-1][1][record.tag_name] = record.tag_value
# Keep only the agent's own spans. With the MockChatGenerator returning no tool calls, the inner
# `haystack.agent.step.tool` span never fires - the loop exits after the LLM call.
agent_spans = [(op, tags) for op, tags in spans if op.startswith("haystack.agent")]
# Exit order (innermost first): LLM child -> step wrapper -> agent.run.
assert [op for op, _ in agent_spans] == ["haystack.agent.step.llm", "haystack.agent.step", "haystack.agent.run"]
# LLM child span carries the chat_generator's input/output, nothing else.
_, llm_tags = agent_spans[0]
assert set(llm_tags) == {"haystack.agent.step.llm.input", "haystack.agent.step.llm.output"}
assert (
llm_tags["haystack.agent.step.llm.input"]
== '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "tools": [{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null, "async_function": null}}]}' # noqa: E501
)
assert (
llm_tags["haystack.agent.step.llm.output"]
== '{"replies": [{"role": "assistant", "meta": {}, "name": null, "content": [{"text": "Hello"}]}]}'
)
# The step wrapper only carries the iteration counter.
_, step_tags = agent_spans[1]
assert step_tags == {"haystack.agent.step": 0}
# agent.run carries the static config + the final input/output/steps-taken summary.
_, run_tags = agent_spans[2]
assert run_tags == {
"haystack.agent.max_steps": 100,
"haystack.agent.tools": '[{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null, "async_function": null}}]', # noqa: E501
"haystack.agent.exit_conditions": '["text"]',
"haystack.agent.state_schema": '{"messages": {"type": "list[haystack.dataclasses.chat_message.ChatMessage]", "handler": "haystack.components.agents.state.state_utils.merge_lists"}, "step_count": {"type": "int", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "token_usage": {"type": "dict[str, typing.Any]", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "tool_call_counts": {"type": "dict[str, int]", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "continue_run": {"type": "bool", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "tools": {"type": "list", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "hook_context": {"type": "dict[str, typing.Any]", "handler": "haystack.components.agents.state.state_utils.replace_values"}}', # noqa: E501
"haystack.agent.input": '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "streaming_callback": null}', # noqa: E501
"haystack.agent.output": '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}, {"role": "assistant", "meta": {}, "name": null, "content": [{"text": "Hello"}]}], "step_count": 1, "token_usage": {}, "tool_call_counts": {"weather_tool": 0}, "last_message": {"role": "assistant", "meta": {}, "name": null, "content": [{"text": "Hello"}]}}', # noqa: E501
"haystack.agent.steps_taken": 1,
}
# Clean up
tracing.tracer.is_content_tracing_enabled = False
tracing.disable_tracing()
def test_agent_tracing_span_run_reflects_runtime_tools(self, caplog, monkeypatch, weather_tool, component_tool):
"""The `haystack.agent.tools` span tag should reflect the tools selected for the run, not just init tools."""
chat_generator = MockChatGeneratorWithoutRunAsync()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool, component_tool])
tracing.tracer.is_content_tracing_enabled = True
tracing.enable_tracing(LoggingTracer())
caplog.set_level(logging.DEBUG)
# Override at runtime to only use weather_tool, even though the agent was configured with both.
_ = agent.run([ChatMessage.from_user("What's the weather in Paris?")], tools=[weather_tool.name])
spans: list[tuple[str, dict[str, Any]]] = []
for record in caplog.records:
if hasattr(record, "operation_name"):
spans.append((record.operation_name, {}))
elif hasattr(record, "tag_name") and spans:
spans[-1][1][record.tag_name] = record.tag_value
run_tags = next(tags for op, tags in spans if op == "haystack.agent.run")
serialized_tools = run_tags["haystack.agent.tools"]
assert "weather_tool" in serialized_tools
assert "parrot" not in serialized_tools
tracing.tracer.is_content_tracing_enabled = False
tracing.disable_tracing()
def test_agent_tracing_span_run_with_tool_call(self, caplog, monkeypatch, weather_tool):
@component
class ToolCallingChatGenerator:
tool_invoked = False
@component.output_types(replies=list[ChatMessage])
def run(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
if self.tool_invoked:
return {"replies": [ChatMessage.from_assistant("done")]}
self.tool_invoked = True
return {
"replies": [
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
)
]
}
agent = Agent(chat_generator=ToolCallingChatGenerator(), tools=[weather_tool])
tracing.tracer.is_content_tracing_enabled = True
tracing.enable_tracing(LoggingTracer())
caplog.set_level(logging.DEBUG)
_ = agent.run([ChatMessage.from_user("What's the weather in Berlin?")])
spans: list[tuple[str, dict[str, Any]]] = []
for record in caplog.records:
if hasattr(record, "operation_name"):
spans.append((record.operation_name, {}))
elif hasattr(record, "tag_name") and spans:
spans[-1][1][record.tag_name] = record.tag_value
agent_spans = [(op, tags) for op, tags in spans if op.startswith("haystack.agent")]
assert [op for op, _ in agent_spans] == [
"haystack.agent.step.llm",
"haystack.agent.step.tool",
"haystack.agent.step",
"haystack.agent.step.llm",
"haystack.agent.step",
"haystack.agent.run",
]
# The single tool call gets its own tool span carrying the tool's identity plus its call args and result.
_, tool_tags = agent_spans[1]
assert set(tool_tags) == {
"haystack.tool.name",
"haystack.tool.description",
"haystack.agent.step.tool.input",
"haystack.agent.step.tool.output",
}
assert tool_tags["haystack.tool.name"] == "weather_tool"
assert tool_tags["haystack.agent.step.tool.input"] == '{"location": "Berlin"}'
_, run_tags = agent_spans[-1]
assert run_tags["haystack.agent.steps_taken"] == 2
tracing.tracer.is_content_tracing_enabled = False
tracing.disable_tracing()
def test_agent_tracing_span_run_with_parallel_tool_calls(self, caplog, monkeypatch, weather_tool):
"""Each tool call in a step gets its own `haystack.agent.step.tool` span instead of one grouped span."""
agent = Agent(chat_generator=ParallelToolCallingChatGenerator(), tools=[weather_tool])
tracing.tracer.is_content_tracing_enabled = True
tracing.enable_tracing(LoggingTracer())
caplog.set_level(logging.DEBUG)
_ = agent.run([ChatMessage.from_user("What's the weather in Berlin and Paris?")])
# The two tool calls run in parallel worker threads; LoggingTracer serializes each span's records so tags
# stay bucketed under their owning span even under concurrency.
spans: list[tuple[str, dict[str, Any]]] = []
for record in caplog.records:
if hasattr(record, "operation_name"):
spans.append((record.operation_name, {}))
elif hasattr(record, "tag_name") and spans:
spans[-1][1][record.tag_name] = record.tag_value
tool_spans = [tags for op, tags in spans if op == "haystack.agent.step.tool"]
# Two tool calls -> two tool spans, one per call, each carrying its own identity, arguments, and result.
assert len(tool_spans) == 2
for tags in tool_spans:
assert set(tags) == {
"haystack.tool.name",
"haystack.tool.description",
"haystack.agent.step.tool.input",
"haystack.agent.step.tool.output",
}
assert {tags["haystack.agent.step.tool.input"] for tags in tool_spans} == {
'{"location": "Berlin"}',
'{"location": "Paris"}',
}
tracing.tracer.is_content_tracing_enabled = False
tracing.disable_tracing()
@pytest.mark.asyncio
async def test_agent_tracing_span_async_run(self, caplog, monkeypatch, weather_tool):
chat_generator = MockChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
tracing.tracer.is_content_tracing_enabled = True
tracing.enable_tracing(LoggingTracer())
caplog.set_level(logging.DEBUG)
_ = await agent.run_async([ChatMessage.from_user("What's the weather in Paris?")])
# Bucket each tag under the operation it was emitted from (see sync version for details).
spans: list[tuple[str, dict[str, Any]]] = []
for record in caplog.records:
if hasattr(record, "operation_name"):
spans.append((record.operation_name, {}))
elif hasattr(record, "tag_name") and spans:
spans[-1][1][record.tag_name] = record.tag_value
agent_spans = [(op, tags) for op, tags in spans if op.startswith("haystack.agent")]
assert [op for op, _ in agent_spans] == ["haystack.agent.step.llm", "haystack.agent.step", "haystack.agent.run"]
_, llm_tags = agent_spans[0]
assert set(llm_tags) == {"haystack.agent.step.llm.input", "haystack.agent.step.llm.output"}
assert (
llm_tags["haystack.agent.step.llm.input"]
== '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "tools": [{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null, "async_function": null}}]}' # noqa: E501
)
assert (
llm_tags["haystack.agent.step.llm.output"]
== '{"replies": [{"role": "assistant", "meta": {}, "name": null, "content": [{"text": "Hello from run_async"}]}]}' # noqa: E501
)
_, step_tags = agent_spans[1]
assert step_tags == {"haystack.agent.step": 0}
_, run_tags = agent_spans[2]
assert run_tags == {
"haystack.agent.max_steps": 100,
"haystack.agent.tools": '[{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null, "async_function": null}}]', # noqa: E501
"haystack.agent.exit_conditions": '["text"]',
"haystack.agent.state_schema": '{"messages": {"type": "list[haystack.dataclasses.chat_message.ChatMessage]", "handler": "haystack.components.agents.state.state_utils.merge_lists"}, "step_count": {"type": "int", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "token_usage": {"type": "dict[str, typing.Any]", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "tool_call_counts": {"type": "dict[str, int]", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "continue_run": {"type": "bool", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "tools": {"type": "list", "handler": "haystack.components.agents.state.state_utils.replace_values"}, "hook_context": {"type": "dict[str, typing.Any]", "handler": "haystack.components.agents.state.state_utils.replace_values"}}', # noqa: E501
"haystack.agent.input": '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "streaming_callback": null}', # noqa: E501
"haystack.agent.output": '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}, {"role": "assistant", "meta": {}, "name": null, "content": [{"text": "Hello from run_async"}]}], "step_count": 1, "token_usage": {}, "tool_call_counts": {"weather_tool": 0}, "last_message": {"role": "assistant", "meta": {}, "name": null, "content": [{"text": "Hello from run_async"}]}}', # noqa: E501
"haystack.agent.steps_taken": 1,
}
# Clean up
tracing.tracer.is_content_tracing_enabled = False
tracing.disable_tracing()
@pytest.mark.asyncio
async def test_agent_tracing_span_async_run_with_parallel_tool_calls(self, caplog, monkeypatch, weather_tool):
"""The async path also emits one `haystack.agent.step.tool` span per tool call."""
agent = Agent(chat_generator=ParallelToolCallingChatGenerator(), tools=[weather_tool])
tracing.tracer.is_content_tracing_enabled = True
tracing.enable_tracing(LoggingTracer())
caplog.set_level(logging.DEBUG)
_ = await agent.run_async([ChatMessage.from_user("What's the weather in Berlin and Paris?")])
spans: list[tuple[str, dict[str, Any]]] = []
for record in caplog.records:
if hasattr(record, "operation_name"):
spans.append((record.operation_name, {}))
elif hasattr(record, "tag_name") and spans:
spans[-1][1][record.tag_name] = record.tag_value
tool_spans = [tags for op, tags in spans if op == "haystack.agent.step.tool"]
assert len(tool_spans) == 2
assert {tags["haystack.agent.step.tool.input"] for tags in tool_spans} == {
'{"location": "Berlin"}',
'{"location": "Paris"}',
}
tracing.tracer.is_content_tracing_enabled = False
tracing.disable_tracing()
def test_agent_tracing_in_pipeline(self, caplog, monkeypatch, weather_tool):
chat_generator = MockChatGeneratorWithoutRunAsync()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
tracing.tracer.is_content_tracing_enabled = True
tracing.enable_tracing(LoggingTracer())
caplog.set_level(logging.DEBUG)
pipeline = Pipeline()
pipeline.add_component(
"prompt_builder", ChatPromptBuilder(template=[ChatMessage.from_user("Hello {{location}}")])
)
pipeline.add_component("agent", agent)
pipeline.connect("prompt_builder.prompt", "agent.messages")
pipeline.run(data={"prompt_builder": {"location": "Berlin"}})
# Bucket each tag under the operation it was emitted from (see sync standalone test for details).
spans: list[tuple[str, dict[str, Any]]] = []
for record in caplog.records:
if hasattr(record, "operation_name"):
spans.append((record.operation_name, {}))
elif hasattr(record, "tag_name") and spans:
spans[-1][1][record.tag_name] = record.tag_value
# Full span hierarchy in exit order (innermost first):
# prompt_builder.component.run -> agent.step.llm -> agent.step -> agent.run -> agent.component.run
# -> pipeline.run
assert [op for op, _ in spans] == [
"haystack.component.run",
"haystack.agent.step.llm",
"haystack.agent.step",
"haystack.agent.run",
"haystack.component.run",
"haystack.pipeline.run",
]
# The two `haystack.component.run` spans wrap prompt_builder and agent respectively.
prompt_builder_component, _ = spans[0], spans[0][1]
assert prompt_builder_component[1]["haystack.component.name"] == "prompt_builder"
agent_component = spans[4]
assert agent_component[1]["haystack.component.name"] == "agent"
# Agent's own spans: shape and ownership identical to the standalone test.
agent_spans = [(op, tags) for op, tags in spans if op.startswith("haystack.agent")]
assert [op for op, _ in agent_spans] == ["haystack.agent.step.llm", "haystack.agent.step", "haystack.agent.run"]
_, llm_tags = agent_spans[0]
assert set(llm_tags) == {"haystack.agent.step.llm.input", "haystack.agent.step.llm.output"}
_, step_tags = agent_spans[1]
assert step_tags == {"haystack.agent.step": 0}
_, run_tags = agent_spans[2]
assert set(run_tags) == {
"haystack.agent.max_steps",
"haystack.agent.tools",
"haystack.agent.exit_conditions",
"haystack.agent.state_schema",
"haystack.agent.input",
"haystack.agent.output",
"haystack.agent.steps_taken",
}
assert run_tags["haystack.agent.steps_taken"] == 1
# And pipeline.run wraps everything, carrying the pipeline-level summary tags.
_, pipeline_tags = spans[-1]
assert set(pipeline_tags) == {
"haystack.pipeline.input_data",
"haystack.pipeline.output_data",
"haystack.pipeline.metadata",
"haystack.pipeline.max_runs_per_component",
"haystack.pipeline.execution_mode",
}
# Clean up
tracing.tracer.is_content_tracing_enabled = False
tracing.disable_tracing()
def test_agent_span_has_parent_when_in_pipeline(self, spying_tracer, weather_tool):
"""Test that the agent's span has the component span as its parent when running in a pipeline."""
chat_generator = MockChatGeneratorWithoutRunAsync()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
pipeline = Pipeline()
pipeline.add_component(
"prompt_builder", ChatPromptBuilder(template=[ChatMessage.from_user("Hello {{location}}")])
)
pipeline.add_component("agent", agent)
pipeline.connect("prompt_builder.prompt", "agent.messages")
pipeline.run(data={"prompt_builder": {"location": "Berlin"}})
# Find the agent span (haystack.agent.run)
agent_spans = [s for s in spying_tracer.spans if s.operation_name == "haystack.agent.run"]
assert len(agent_spans) == 1
agent_span = agent_spans[0]
# Find the agent's component span (the outer span for the Agent component)
agent_component_spans = [
s
for s in spying_tracer.spans
if s.operation_name == "haystack.component.run" and s.tags.get("haystack.component.name") == "agent"
]
assert len(agent_component_spans) == 1
agent_component_span = agent_component_spans[0]
# Verify the agent span has the component span as its parent
assert agent_span.parent_span is not None
assert agent_span.parent_span == agent_component_span
class TestSelectToolsByName:
"""Tests for the _select_tools_by_name helper (runtime tool-name selection)."""
def test_selects_standalone_tools_by_name(self, weather_tool: Tool, component_tool: Tool):
result = _select_tools_by_name([weather_tool, component_tool], [weather_tool.name])
assert result == [weather_tool]
def test_raises_on_invalid_name(self, weather_tool: Tool, component_tool: Tool):
with pytest.raises(
ValueError, match="The following tool names are not valid: {'invalid_tool_name'}. Valid tool names are: ."
):
_select_tools_by_name([weather_tool, component_tool], ["invalid_tool_name"])
def test_raises_when_no_tools_configured(self, weather_tool: Tool):
with pytest.raises(ValueError, match="No tools were configured for the Agent at initialization."):
_select_tools_by_name([], [weather_tool.name])
def test_returns_isolated_spawn_with_selection(self, weather_tool: Tool, component_tool: Tool):
"""A Toolset exposing a requested name is replaced by an isolated spawn carrying the selection.
The shared, configured Toolset is not mutated.
"""
toolset = Toolset([weather_tool, component_tool])
result = _select_tools_by_name([toolset], [weather_tool.name])
assert len(result) == 1
spawned = result[0]
assert isinstance(spawned, Toolset)
assert spawned is not toolset # an isolated per-run copy
assert spawned._selected_tool_names == {weather_tool.name}
assert [tool.name for tool in spawned] == [weather_tool.name]
# The configured toolset is untouched.
assert toolset._selected_tool_names is None
def test_mixed_standalone_tools_and_toolsets(self, weather_tool: Tool, component_tool: Tool):
toolset = Toolset([weather_tool])
result = _select_tools_by_name([component_tool, toolset], [weather_tool.name, component_tool.name])
# The standalone tool is passed through; the toolset is replaced by an isolated spawn with the selection.
assert component_tool in result
spawns = [t for t in result if isinstance(t, Toolset)]
assert len(spawns) == 1
assert spawns[0] is not toolset
assert spawns[0]._selected_tool_names == {weather_tool.name}
assert toolset._selected_tool_names is None
class TestAgentToolSelection:
def test_tool_selection_new_tool(self, weather_tool: Tool, component_tool: Tool):
chat_generator = MockChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], system_prompt="This is a system prompt.")
result = agent._select_tools([component_tool])
assert result == [component_tool]
def test_tool_selection_existing_tools(self, weather_tool: Tool, component_tool: Tool):
chat_generator = MockChatGenerator()
agent = Agent(
chat_generator=chat_generator,
tools=[weather_tool, component_tool],
system_prompt="This is a system prompt.",
)
result = agent._select_tools(None)
assert result == [weather_tool, component_tool]
def test_tool_selection_invalid_type(self, weather_tool: Tool, component_tool: Tool):
chat_generator = MockChatGenerator()
agent = Agent(
chat_generator=chat_generator,
tools=[weather_tool, component_tool],
system_prompt="This is a system prompt.",
)
with pytest.raises(
TypeError,
match=(
re.escape(
"tools must be a list of Tool and/or Toolset objects, a Toolset, or a list of tool names (strings)."
)
),
):
agent._select_tools("invalid_tool_name")
def test_tool_selection_with_list_of_toolsets(self, weather_tool: Tool, component_tool: Tool):
"""Test that list of Toolsets and Tools can be passed to agent."""
chat_generator = MockChatGenerator()
toolset1 = Toolset([weather_tool])
standalone_tool = Tool(
name="standalone",
description="A standalone tool",
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
function=lambda x: f"Result: {x}",
)
toolset2 = Toolset([component_tool])
agent = Agent(chat_generator=chat_generator, tools=[toolset1, standalone_tool, toolset2])
result = agent._select_tools(None)
assert result == [toolset1, standalone_tool, toolset2]
assert isinstance(result, list)
assert len(result) == 3
def test_agent_serde_with_list_of_toolsets(self, weather_tool: Tool, component_tool: Tool, monkeypatch):
"""Test Agent serialization and deserialization with a list of Toolsets."""
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
toolset1 = Toolset([weather_tool])
toolset2 = Toolset([component_tool])
generator = OpenAIChatGenerator()
agent = Agent(chat_generator=generator, tools=[toolset1, toolset2])
serialized_agent = agent.to_dict()
# Verify serialization preserves list[Toolset] structure
tools_data = serialized_agent["init_parameters"]["tools"]
assert isinstance(tools_data, list)
assert len(tools_data) == 2
assert all(isinstance(ts, dict) for ts in tools_data)
assert tools_data[0]["type"] == "haystack.tools.toolset.Toolset"
assert tools_data[1]["type"] == "haystack.tools.toolset.Toolset"
# Deserialize and verify
deserialized_agent = Agent.from_dict(serialized_agent)
assert isinstance(deserialized_agent.tools, list)
assert len(deserialized_agent.tools) == 2
assert all(isinstance(ts, Toolset) for ts in deserialized_agent.tools)
class TestRegisterPromptVariables:
def test_register_prompt_variables_warning_when_no_prompt_and_required_variables(self, make_agent, caplog):
make_agent(required_variables=["name"])
assert "The parameter required_variables is provided but neither" in caplog.text
def test_register_prompt_variables_set_all_variables_as_required(self, make_agent):
agent = make_agent(user_prompt=_user_msg("Question: {{question}}"), required_variables="*")
assert agent._user_chat_prompt_builder.required_variables == "*"
input_names = set(agent.__haystack_input__._sockets_dict.keys())
assert "question" in input_names
def test_register_prompt_variables_set_required_variables_on_builder(self, make_agent):
agent = make_agent(user_prompt=_user_msg("Question: {{question}}"), required_variables=["question"])
assert agent._user_chat_prompt_builder.required_variables == ["question"]
input_names = set(agent.__haystack_input__._sockets_dict.keys())
assert "question" in input_names
def test_register_prompt_variables_raises_on_state_schema_conflict(self, make_agent):
with pytest.raises(
ValueError, match="Variable 'question' from user_prompt is already defined in the state schema."
):
make_agent(user_prompt=_user_msg("Question: {{question}}"), state_schema={"question": {"type": str}})
def test_register_prompt_variables_raises_on_run_param_conflict(self, make_agent):
with pytest.raises(
ValueError,
match="Variable 'streaming_callback' from user_prompt conflicts with input names in the run method.",
):
make_agent(user_prompt=_user_msg("{{streaming_callback}} is already a run parameter."))
class TestPrompts:
def test_system_prompt_incorrect_jinja2_syntax_raises(self, make_agent):
with pytest.raises(TemplateSyntaxError):
make_agent(system_prompt="{% message role='system' %}Incomplete syntax.")
def test_system_prompt_plain_string(self, make_agent):
agent = make_agent(system_prompt="You are a helpful assistant.")
assert agent._system_chat_prompt_builder is not None
result = agent.run(messages=[ChatMessage.from_user("Hi")])
assert result["messages"][0].is_from(ChatRole.SYSTEM)
assert result["messages"][0].text == "You are a helpful assistant."
def test_system_prompt_plain_string_with_template_variables(self, make_agent):
agent = make_agent(system_prompt="You are an assistant for {{company}}. Your role is {{role}}.")
assert agent._system_chat_prompt_builder is not None
assert set(agent._system_chat_prompt_builder.variables) == {"company", "role"}
result = agent.run(messages=[ChatMessage.from_user("Hi")], company="Acme", role="support agent")
sys_msg = result["messages"][0]
assert sys_msg.is_from(ChatRole.SYSTEM)
assert sys_msg.text == "You are an assistant for Acme. Your role is support agent."
input_names = set(agent.__haystack_input__._sockets_dict.keys())
assert "company" in input_names
assert "role" in input_names
def test_system_prompt_with_template_variables(self, make_agent):
agent = make_agent(system_prompt=_sys_msg("You are an assistant for {{company}}. Your role is {{role}}."))
assert agent._system_chat_prompt_builder is not None
assert set(agent._system_chat_prompt_builder.variables) == {"company", "role"}
result = agent.run(messages=[ChatMessage.from_user("Hi")], company="Acme", role="support agent")
sys_msg = result["messages"][0]
assert sys_msg.is_from(ChatRole.SYSTEM)
assert sys_msg.text == "You are an assistant for Acme. Your role is support agent."
input_names = set(agent.__haystack_input__._sockets_dict.keys())
assert "company" in input_names
assert "role" in input_names
def test_system_prompt_with_meta(self, make_agent):
agent = make_agent(
system_prompt="{% message role='system' meta={'key': 'value'} %}System message with meta{% endmessage %}"
)
assert agent._system_chat_prompt_builder is not None
result = agent.run(messages=[ChatMessage.from_user("Hi")])
messages = result["messages"]
assert messages[0].is_from(ChatRole.SYSTEM)
assert messages[0].text == "System message with meta"
assert messages[0].meta == {"key": "value"}
def test_user_prompt_only_variables_forwarded_to_builder(self, make_agent):
agent = make_agent(user_prompt=_user_msg("Question: {{question}}"))
# 'irrelevant_kwarg' is not a template variable — must not raise
result = agent.run(messages=[], question="Will it snow?", irrelevant_kwarg="unused")
assert "messages" in result
def test_user_prompt_plain_string_with_template_variables(self, make_agent):
agent = make_agent(user_prompt="Question: {{question}}")
result = agent.run(messages=[], question="Will it snow?")
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert user_messages[0].text == "Question: Will it snow?"
input_names = set(agent.__haystack_input__._sockets_dict.keys())
assert "question" in input_names
def test_user_prompt_with_template_variables(self, make_agent):
agent = make_agent(
user_prompt=_user_msg(
"Hello {{name|upper}}, check weather for: "
+ "{% for c in cities %}{{c}}{% if not loop.last %}, {% endif %}{% endfor %}"
+ " on {{date}}?"
)
)
result = agent.run(messages=[], name="Alice", cities=["Berlin", "Paris", "Rome"], date="2024-01-15")
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert user_messages[0].text == "Hello ALICE, check weather for: Berlin, Paris, Rome on 2024-01-15?"
input_names = set(agent.__haystack_input__._sockets_dict.keys())
assert "name" in input_names
assert "cities" in input_names
assert "date" in input_names
def test_user_prompt_appended_after_initial_messages(self, make_agent):
agent = make_agent(user_prompt=_user_msg("And now: {{query}}"))
initial_messages = [ChatMessage.from_user("First message")]
result = agent.run(messages=initial_messages, query="What is the weather?")
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert user_messages[0].text == "First message"
assert user_messages[1].text == "And now: What is the weather?"
def test_system_prompt_and_user_prompt(self, make_agent):
agent = make_agent(
system_prompt=_sys_msg("You help users of {{project}}."),
user_prompt=_user_msg("Tell me about {{topic}} in the {{project}} context."),
)
assert agent._system_chat_prompt_builder is not None
assert agent._user_chat_prompt_builder is not None
result = agent.run(messages=[], project="Haystack", topic="pipelines")
messages = result["messages"]
assert messages[0].is_from(ChatRole.SYSTEM)
assert messages[0].text == "You help users of Haystack."
user_messages = [m for m in messages if m.is_from(ChatRole.USER)]
assert user_messages[0].text == "Tell me about pipelines in the Haystack context."
def test_prompt_wrong_role_raises_at_init(self, make_agent):
with pytest.raises(ValueError, match="system_prompt message block must have role 'system'"):
make_agent(system_prompt=_user_msg("This is a user message, not system."))
with pytest.raises(ValueError, match="user_prompt message block must have role 'user'"):
make_agent(user_prompt=_sys_msg("This is a system message, not user."))
def test_dynamic_prompt_role_raises_at_runtime(self, make_agent):
agent = make_agent(user_prompt="{% message role=role_name %}Question: {{question}}{% endmessage %}")
with pytest.raises(ValueError, match="user_prompt must render to a user message"):
agent.run(messages=[], role_name="assistant", question="Will it snow?")
def test_prompt_multiple_message_blocks_raises_at_init(self, make_agent):
multi_message_prompt = """{% message role='system' %}You are a helpful assistant.{% endmessage %}
{% message role='user' %}How are you?{% endmessage %}"""
with pytest.raises(ValueError, match="system_prompt must define exactly one message block"):
make_agent(system_prompt=multi_message_prompt)
with pytest.raises(ValueError, match="user_prompt must define exactly one message block"):
make_agent(user_prompt=multi_message_prompt)
@pytest.mark.integration
class TestAgentUserPromptInPipeline:
@pytest.fixture
def document_store_with_docs(self):
store = InMemoryDocumentStore()
store.write_documents(
[
Document(content="The Eiffel Tower is located in Paris."),
Document(content="The Brandenburg Gate is in Berlin."),
Document(content="The Colosseum is in Rome."),
]
)
return store
@pytest.fixture
def make_rag_pipeline(self, document_store_with_docs: InMemoryDocumentStore, make_agent):
def _factory(user_prompt: str | None = None):
agent = make_agent(
user_prompt=user_prompt
or _user_msg(
"Use the following documents to answer the question.\n"
"Documents:\n{% for doc in documents %}{{doc.content}}\n{% endfor %}"
"Question: {{query}}"
),
system_prompt="You are a knowledgeable assistant.",
required_variables=["query", "documents"],
)
pp = Pipeline()
pp.add_component("retriever", InMemoryBM25Retriever(document_store=document_store_with_docs))
pp.add_component("agent", agent)
pp.connect("retriever.documents", "agent.documents")
return pp
return _factory
def test_rag_pipeline_user_prompt_init_only(self, make_rag_pipeline):
pipeline = make_rag_pipeline()
query = "Where is the Colosseum?"
result = pipeline.run(data={"retriever": {"query": query}, "agent": {"query": query, "messages": []}})
assert "agent" in result
agent_output = result["agent"]
assert "messages" in agent_output
assert "last_message" in agent_output
messages = agent_output["messages"]
assert messages[0].is_from(ChatRole.SYSTEM)
assert messages[0].text == "You are a knowledgeable assistant."
user_messages = [m for m in messages if m.is_from(ChatRole.USER)]
assert len(user_messages) == 1
rendered = user_messages[0].text
assert "Question: Where is the Colosseum?" in rendered
assert "Documents:" in rendered
def test_rag_pipeline_messages_plus_user_prompt(self, document_store_with_docs, weather_tool):
chat_generator = MockChatGenerator()
agent = Agent(
chat_generator=chat_generator,
tools=[weather_tool],
user_prompt=_user_msg("Relevant docs:\n{% for doc in documents %}{{doc.content}}\n{% endfor %}"),
)
chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("Berlin")]})
pipeline = Pipeline()
pipeline.add_component(
"prompt_builder", ChatPromptBuilder(template=[ChatMessage.from_user("History: {{history_note}}")])
)
pipeline.add_component("retriever", InMemoryBM25Retriever(document_store=document_store_with_docs))
pipeline.add_component("agent", agent)
pipeline.connect("prompt_builder.prompt", "agent.messages")
pipeline.connect("retriever.documents", "agent.documents")
result = pipeline.run(
data={
"prompt_builder": {"history_note": "User previously asked about European cities."},
"retriever": {"query": "Brandenburg Gate"},
}
)
messages = result["agent"]["messages"]
user_messages = [m for m in messages if m.is_from(ChatRole.USER)]
assert "History:" in user_messages[0].text
rendered = user_messages[1].text
assert "Relevant docs:" in rendered
class TestAgentWaitsForBlockedPredecessor:
"""
Regression test for the scheduling bug introduced by making the 'messages'
run parameter non-required in https://github.com/deepset-ai/haystack/pull/10638.
Pipeline shape
--------------
Two paths feed into a lazy-variadic joiner that collects messages for the Agent:
Path A (works): query → history_parser → messages_joiner.values
Path B (blocked): files=[] → files_processor (returns {}) → attachments_builder ──╳──→ messages_joiner.values
messages_joiner.values → agent.messages
filters → agent.retrieval_filters (static input from pipeline.run data)
The bug
-------
1. history_parser runs → sends messages to messages_joiner.
2. files_processor runs with files=[] → returns {} (no output).
3. attachments_builder is BLOCKED — its mandatory processed_files input never arrives.
4. messages_joiner gets DEFER: it has a lazy-variadic socket and attachments_builder hasn't executed yet,
so the joiner doesn't know if more data might still come. It keeps waiting.
5. agent also gets DEFER: retrieval_filters arrives with sender=None (static pipeline input), which
satisfies has_any_trigger() on the first visit. The Agent has no mandatory sockets, so can_component_run()
returns True.
6. The scheduler tie-breaks DEFER components by topological order, so the joiner should run before the Agent.
Before the fix the Agent was picked first and executed without messages, raising:
ValueError("No messages provided to the Agent and neither user_prompt nor system_prompt is set.")
"""
def test_agent_waits_for_messages_when_predecessor_is_blocked(self, weather_tool):
@component
class HistoryParser:
@component.output_types(messages=list[ChatMessage])
def run(self, query: str) -> dict:
return {"messages": [ChatMessage.from_user(query)]}
@component
class FilesProcessor:
"""Produces no output when given an empty file list."""
@component.output_types(processed_files=list[str])
def run(self, files: list[str]) -> dict:
if not files:
return {} # _NO_OUTPUT_PRODUCED → blocks AttachmentsBuilder
return {"processed_files": files}
@component
class AttachmentsBuilder:
"""Builds attachment messages; mandatory processed_files from FilesProcessor."""
@component.output_types(prompt=list[ChatMessage])
def run(self, processed_files: list[str]) -> dict:
return {"prompt": [ChatMessage.from_user(f"Files: {processed_files}")]}
chat_generator = MockChatGenerator()
agent = Agent(
chat_generator=chat_generator,
tools=[weather_tool],
state_schema={"retrieval_filters": {"type": dict[str, Any]}},
)
chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("done")]})
pipeline = Pipeline()
pipeline.add_component("history_parser", HistoryParser())
pipeline.add_component("files_processor", FilesProcessor())
pipeline.add_component("attachments_builder", AttachmentsBuilder())
pipeline.add_component("messages_joiner", ListJoiner(list[ChatMessage]))
pipeline.add_component("agent", agent)
pipeline.connect("history_parser.messages", "messages_joiner.values")
pipeline.connect("files_processor.processed_files", "attachments_builder.processed_files")
pipeline.connect("attachments_builder.prompt", "messages_joiner.values")
pipeline.connect("messages_joiner.values", "agent.messages")
# files=[] → files_processor produces no output → attachments_builder BLOCKED
# → messages_joiner stays DEFER waiting for the blocked branch
# → agent (DEFER) must wait for the joiner via topological tie-break
result = pipeline.run(
data={
"history_parser": {"query": "What case law applies?"},
"files_processor": {"files": []}, # empty → no output
"agent": {"retrieval_filters": {"field": "date", "value": "2024-01-01"}},
}
)
assert "agent" in result
class TestAgentWarmUp:
"""Tests that Agent.warm_up() correctly warms up tools and toolsets."""
def _make_tracking_tool(self, name: str = "test_tool") -> Tool:
tool = Tool(
name=name,
description="A test tool",
parameters={"type": "object", "properties": {}},
function=lambda: "result",
)
tool.was_warmed_up = False
original_warm_up = tool.warm_up
def tracking_warm_up():
original_warm_up()
tool.was_warmed_up = True
tool.warm_up = tracking_warm_up
return tool
def _make_tracking_toolset(self, tools: list) -> Toolset:
toolset = Toolset(tools)
toolset.was_warmed_up = False
original_warm_up = toolset.warm_up
def tracking_warm_up():
original_warm_up()
toolset.was_warmed_up = True
toolset.warm_up = tracking_warm_up
return toolset
def test_warm_up_single_tool(self):
tool = self._make_tracking_tool()
agent = Agent(chat_generator=MockChatGenerator(), tools=[tool])
assert not tool.was_warmed_up
agent.warm_up()
assert tool.was_warmed_up
def test_warm_up_multiple_tools(self):
tool1 = self._make_tracking_tool("tool1")
tool2 = self._make_tracking_tool("tool2")
agent = Agent(chat_generator=MockChatGenerator(), tools=[tool1, tool2])
assert not tool1.was_warmed_up
assert not tool2.was_warmed_up
agent.warm_up()
assert tool1.was_warmed_up
assert tool2.was_warmed_up
def test_warm_up_toolset(self):
inner_tool = self._make_tracking_tool()
toolset = self._make_tracking_toolset([inner_tool])
agent = Agent(chat_generator=MockChatGenerator(), tools=toolset)
assert not toolset.was_warmed_up
agent.warm_up()
assert toolset.was_warmed_up
def test_warm_up_mixed_toolsets(self):
tool1 = self._make_tracking_tool("tool1")
toolset1 = self._make_tracking_toolset([tool1])
tool2 = self._make_tracking_tool("tool2")
toolset2 = self._make_tracking_toolset([tool2])
agent = Agent(chat_generator=MockChatGenerator(), tools=toolset1 + toolset2)
assert not toolset1.was_warmed_up
assert not toolset2.was_warmed_up
agent.warm_up()
assert toolset1.was_warmed_up
assert toolset2.was_warmed_up
def test_warm_up_mixed_list_of_tools_and_toolsets(self):
tool1 = self._make_tracking_tool("standalone_tool1")
tool2 = self._make_tracking_tool("standalone_tool2")
tool3 = self._make_tracking_tool("toolset_tool1")
toolset1 = self._make_tracking_toolset([tool3])
tool4 = self._make_tracking_tool("toolset_tool2")
toolset2 = self._make_tracking_toolset([tool4])
agent = Agent(chat_generator=MockChatGenerator(), tools=[tool1, toolset1, tool2, toolset2])
assert not tool1.was_warmed_up
assert not tool2.was_warmed_up
assert not toolset1.was_warmed_up
assert not toolset2.was_warmed_up
agent.warm_up()
assert tool1.was_warmed_up
assert tool2.was_warmed_up
assert toolset1.was_warmed_up
assert toolset2.was_warmed_up
def test_warm_up_is_idempotent(self):
call_count = {"n": 0}
tool = Tool(
name="counting_tool",
description="A tool that counts warm_up calls",
parameters={"type": "object", "properties": {}},
function=lambda: "test",
)
original = tool.warm_up
def counting_warm_up():
original()
call_count["n"] += 1
tool.warm_up = counting_warm_up
agent = Agent(chat_generator=MockChatGenerator(), tools=[tool])
agent.warm_up()
agent.warm_up()
agent.warm_up()
assert call_count["n"] == 1
def test_warm_up_refreshes_toolset(self):
"""Agent.warm_up() must warm up lazy toolsets (e.g. MCPToolset) so the actual tools are available at runtime."""
placeholder_tool = Tool(
name="mcp_not_connected_placeholder_123",
description="Placeholder tool before connection",
parameters={"type": "object", "properties": {}},
function=lambda: "placeholder",
)
actual_tool = Tool(
name="get_time",
description="Get the current time in ISO format",
parameters={"type": "object", "properties": {}, "required": []},
function=lambda: "2024-12-01T12:00:00Z",
)
class MockMCPToolset(Toolset):
def __init__(self):
super().__init__([placeholder_tool])
self._connected = False
def warm_up(self):
if not self._connected:
self.tools = [actual_tool]
self._connected = True
mcp_toolset = MockMCPToolset()
agent = Agent(chat_generator=MockChatGenerator(), tools=mcp_toolset)
assert mcp_toolset.tools == [placeholder_tool]
agent.warm_up()
assert mcp_toolset.tools == [actual_tool]
def test_run_warms_lazy_toolset_before_tool_selection(self):
"""
Agent.run() must warm up lazy toolsets before passing tools to the ChatGenerator and before executing tool calls
"""
placeholder_tool = Tool(
name="mcp_not_connected_placeholder_123",
description="Placeholder tool before connection",
parameters={"type": "object", "properties": {}},
function=lambda: "placeholder",
)
actual_tool = Tool(
name="get_time",
description="Get the current time in ISO format",
parameters={"type": "object", "properties": {}, "required": []},
function=lambda: "2024-12-01T12:00:00Z",
)
class MockMCPToolset(Toolset):
def __init__(self):
super().__init__([placeholder_tool])
self._connected = False
def warm_up(self):
if not self._connected:
self.tools = [actual_tool]
self._connected = True
@component
class ToolCallingChatGenerator:
tool_invoked = False
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: Toolset | None = None, **kwargs) -> dict[str, Any]:
assert tools is not None
assert [tool.name for tool in tools] == ["get_time"]
if self.tool_invoked:
return {"replies": [ChatMessage.from_assistant("done")]}
self.tool_invoked = True
return {
"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="get_time", arguments={})])]
}
mcp_toolset = MockMCPToolset()
agent = Agent(chat_generator=ToolCallingChatGenerator(), tools=mcp_toolset)
result = agent.run([ChatMessage.from_user("What time is it?")])
assert mcp_toolset.tools == [actual_tool]
assert result["messages"][2].tool_call_result.result == "2024-12-01T12:00:00Z"
assert result["last_message"].text == "done"
def test_run_warms_up_per_run_toolset(self):
"""Per-run tools passed to run() are not covered by Agent.warm_up() and must be warmed up at run time."""
init_tool = self._make_tracking_tool("init_tool")
agent = Agent(chat_generator=MockChatGenerator(), tools=Toolset([init_tool]))
agent.warm_up()
per_run_tool = self._make_tracking_tool("per_run_tool")
per_run_toolset = self._make_tracking_toolset([per_run_tool])
assert not per_run_toolset.was_warmed_up
assert not per_run_tool.was_warmed_up
agent.run(messages=[ChatMessage.from_user("hi")], tools=per_run_toolset)
assert per_run_toolset.was_warmed_up
assert per_run_tool.was_warmed_up
def test_run_warms_up_per_run_list_of_tools_and_toolsets(self):
"""A per-run list of Tools and Toolsets must be warmed up at run time."""
init_tool = self._make_tracking_tool("init_tool")
agent = Agent(chat_generator=MockChatGenerator(), tools=[init_tool])
agent.warm_up()
per_run_tool = self._make_tracking_tool("per_run_tool")
toolset_tool = self._make_tracking_tool("toolset_tool")
per_run_toolset = self._make_tracking_toolset([toolset_tool])
agent.run(messages=[ChatMessage.from_user("hi")], tools=[per_run_tool, per_run_toolset])
assert per_run_tool.was_warmed_up
assert per_run_toolset.was_warmed_up
assert toolset_tool.was_warmed_up
@pytest.mark.asyncio
async def test_run_async_warms_up_per_run_toolset(self):
"""The async run path must also warm up per-run tools."""
init_tool = self._make_tracking_tool("init_tool")
agent = Agent(chat_generator=MockChatGenerator(), tools=Toolset([init_tool]))
agent.warm_up()
per_run_tool = self._make_tracking_tool("per_run_tool")
per_run_toolset = self._make_tracking_toolset([per_run_tool])
await agent.run_async(messages=[ChatMessage.from_user("hi")], tools=per_run_toolset)
assert per_run_toolset.was_warmed_up
assert per_run_tool.was_warmed_up
class TestComponentLifecycle:
def test_warm_up_delegates_to_chat_generator(self, weather_tool):
chat_generator = MockChatGenerator()
chat_generator.warm_up = MagicMock()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], system_prompt="This is a system prompt.")
agent.warm_up()
chat_generator.warm_up.assert_called_once()
chat_generator.warm_up.reset_mock()
agent.run([ChatMessage.from_user("What is the weather in Berlin?")])
assert agent._tools_warmed_up is True
chat_generator.warm_up.assert_called_once()
@pytest.mark.asyncio
async def test_warm_up_async_delegates_to_chat_generator(self):
chat_generator = MockChatGenerator()
chat_generator.warm_up_async = AsyncMock()
chat_generator.warm_up = MagicMock()
agent = Agent(chat_generator=chat_generator, tools=[])
await agent.warm_up_async()
chat_generator.warm_up_async.assert_awaited_once()
chat_generator.warm_up.assert_not_called()
@pytest.mark.asyncio
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
chat_generator = MockChatGeneratorWithoutRunAsync()
chat_generator.warm_up = MagicMock()
agent = Agent(chat_generator=chat_generator, tools=[])
await agent.warm_up_async()
chat_generator.warm_up.assert_called_once()
def test_close_delegates_to_chat_generator(self):
chat_generator = MockChatGenerator()
chat_generator.close = MagicMock()
agent = Agent(chat_generator=chat_generator, tools=[])
agent.close()
chat_generator.close.assert_called_once()
@pytest.mark.asyncio
async def test_close_async_delegates_to_chat_generator(self):
chat_generator = MockChatGenerator()
chat_generator.close_async = AsyncMock()
agent = Agent(chat_generator=chat_generator, tools=[])
await agent.close_async()
chat_generator.close_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_close_async_falls_back_to_sync_close(self):
chat_generator = MockChatGenerator()
chat_generator.close = MagicMock()
agent = Agent(chat_generator=chat_generator, tools=[])
await agent.close_async()
chat_generator.close.assert_called_once()
@pytest.mark.asyncio
async def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
agent = Agent(chat_generator=MockChatGeneratorWithoutRunAsync(), tools=[])
agent.warm_up()
await agent.warm_up_async()
agent.close()
await agent.close_async()
class TestAgentNotTriggeredByInjectedInput:
"""
Regression test for https://github.com/deepset-ai/haystack/issues/11109.
ConditionalRouter routes to `planning`, BranchJoiner never runs, so Agent.messages
gets no input. A `streaming_callback` injected via `pipeline.run` data must not
by itself trigger the Agent (would happen if `messages` were optional, since any
`sender=None` entry flips `has_user_input()` to True).
"""
def test_agent_not_triggered_by_injected_streaming_callback(self, weather_tool):
@component
class Planner:
@component.output_types(messages=list[ChatMessage], last_role=str)
def run(self) -> dict:
return {"messages": [ChatMessage.from_assistant("?")], "last_role": "assistant"}
chat_generator = MockChatGenerator()
agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("x")]})
router = ConditionalRouter(
routes=[
{
"condition": "{{ last_role == 'tool' }}",
"output": "{{ messages }}",
"output_name": "processing",
"output_type": list[ChatMessage],
},
{
"condition": "{{ True }}",
"output": "{{ messages }}",
"output_name": "planning",
"output_type": list[ChatMessage],
},
],
unsafe=True,
)
pipeline = Pipeline()
pipeline.add_component("planner", Planner())
pipeline.add_component("router", router)
pipeline.add_component("branch_joiner", BranchJoiner(type_=list[ChatMessage]))
pipeline.add_component("agent", agent)
pipeline.connect("planner.messages", "router.messages")
pipeline.connect("planner.last_role", "router.last_role")
pipeline.connect("router.processing", "branch_joiner.value")
pipeline.connect("branch_joiner.value", "agent.messages")
result = pipeline.run(data={"agent": {"streaming_callback": sync_streaming_callback}})
assert "agent" not in result
chat_generator.run.assert_not_called()