diff --git a/haystack/components/agents/tool_calling.py b/haystack/components/agents/tool_calling.py index c0d3c21b4a..978ec042ae 100644 --- a/haystack/components/agents/tool_calling.py +++ b/haystack/components/agents/tool_calling.py @@ -18,6 +18,7 @@ from haystack.tools import ComponentTool, Tool, ToolsType, _check_duplicate_tool_names, flatten_tools_or_toolsets from haystack.tools.errors import ToolInvocationError from haystack.tools.parameters_schema_utils import _is_state_annotation +from haystack.tools.tool import _is_single_output_config from haystack.tracing.utils import _serializable_value logger = logging.getLogger(__name__) @@ -146,7 +147,7 @@ def _build_tool_result_message(result: Any, tool_call: ToolCall, tool: Tool, *, outputs_config = tool.outputs_to_string or {} # Single-output config (or no config): keys are at the root level - if not outputs_config or any(k in outputs_config for k in ("source", "handler", "raw_result")): + if not outputs_config or _is_single_output_config(outputs_config): tool_result = _process_tool_output(outputs_config, result, tool_call, raise_on_failure=raise_on_failure) return ChatMessage.from_tool(tool_result=tool_result, origin=tool_call) diff --git a/haystack/tools/tool.py b/haystack/tools/tool.py index 6a569a7d8a..dd497db951 100644 --- a/haystack/tools/tool.py +++ b/haystack/tools/tool.py @@ -157,19 +157,17 @@ def __post_init__(self) -> None: # noqa: C901, PLR0912 ) if self.outputs_to_string is not None: - if "source" in self.outputs_to_string and not isinstance(self.outputs_to_string["source"], str): - raise ValueError("outputs_to_string source must be a string.") - if "handler" in self.outputs_to_string and not callable(self.outputs_to_string["handler"]): - raise ValueError("outputs_to_string handler must be callable") - if "raw_result" in self.outputs_to_string and not isinstance(self.outputs_to_string["raw_result"], bool): - raise ValueError("outputs_to_string raw_result must be a boolean.") - - if ( - "source" in self.outputs_to_string - or "handler" in self.outputs_to_string - or "raw_result" in self.outputs_to_string - ): + if _is_single_output_config(self.outputs_to_string): # Single output configuration + if "source" in self.outputs_to_string and not isinstance(self.outputs_to_string["source"], str): + raise ValueError("outputs_to_string source must be a string.") + if "handler" in self.outputs_to_string and not callable(self.outputs_to_string["handler"]): + raise ValueError("outputs_to_string handler must be callable") + if "raw_result" in self.outputs_to_string and not isinstance( + self.outputs_to_string["raw_result"], bool + ): + raise ValueError("outputs_to_string raw_result must be a boolean.") + for key in self.outputs_to_string: if key not in {"source", "handler", "raw_result"}: raise ValueError( @@ -425,6 +423,13 @@ def _deserialize_outputs_to_state(outputs_to_state: dict[str, dict[str, Any]]) - return _convert_handler_in_configs(outputs_to_state, deserialize_callable) +def _is_single_output_config(outputs_to_string: dict[str, Any]) -> bool: + return any( + key in outputs_to_string and not isinstance(outputs_to_string[key], dict) + for key in ("source", "handler", "raw_result") + ) + + def _serialize_outputs_to_string(outputs_to_string: dict[str, Any]) -> dict[str, Any]: """ Serializes the outputs_to_string dictionary, converting any callable handlers to their string representation. @@ -432,7 +437,7 @@ def _serialize_outputs_to_string(outputs_to_string: dict[str, Any]) -> dict[str, :param outputs_to_string: The outputs_to_string dictionary to serialize. :returns: The serialized outputs_to_string dictionary. """ - if "source" in outputs_to_string or "handler" in outputs_to_string or "raw_result" in outputs_to_string: + if _is_single_output_config(outputs_to_string): # Single output configuration return _convert_handler(outputs_to_string, serialize_callable) @@ -447,7 +452,7 @@ def _deserialize_outputs_to_string(outputs_to_string: dict[str, Any]) -> dict[st :param outputs_to_string: The outputs_to_string dictionary to deserialize. :returns: The deserialized outputs_to_string dictionary. """ - if "source" in outputs_to_string or "handler" in outputs_to_string or "raw_result" in outputs_to_string: + if _is_single_output_config(outputs_to_string): # Single output configuration return _convert_handler(outputs_to_string, deserialize_callable) diff --git a/releasenotes/notes/fix-tool-output-config-42fbba51752294c1.yaml b/releasenotes/notes/fix-tool-output-config-42fbba51752294c1.yaml new file mode 100644 index 0000000000..b1d6756390 --- /dev/null +++ b/releasenotes/notes/fix-tool-output-config-42fbba51752294c1.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + Fix ``Tool.outputs_to_string`` handling of multiple outputs named ``source``, + ``handler``, or ``raw_result``, including serialization of their handlers. diff --git a/test/components/agents/test_tool_calling.py b/test/components/agents/test_tool_calling.py index 943372dc7c..9fd7242695 100644 --- a/test/components/agents/test_tool_calling.py +++ b/test/components/agents/test_tool_calling.py @@ -692,19 +692,20 @@ def test_tool_invocation_error_does_not_raise_exception(self, faulty_tool): assert tool_message.tool_call_results[0].error assert "Failed to invoke" in tool_message.tool_call_results[0].result - def test_outputs_to_string_with_multiple_outputs(self): + @pytest.mark.parametrize("output_key", ["weather", "source", "handler", "raw_result"]) + def test_outputs_to_string_with_multiple_outputs(self, output_key): weather_tool = Tool( name="weather_tool", description="Provides weather information for a given location.", parameters=weather_parameters, function=weather_function, - outputs_to_string={"weather": {"source": "weather"}, "temp": {"source": "temperature"}}, + outputs_to_string={output_key: {"source": "weather"}, "temp": {"source": "temperature"}}, ) tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}) tool_result = {"weather": "sunny", "temperature": 25, "unit": "celsius"} chat_message = _build_tool_result_message(tool_result, tool_call, weather_tool, raise_on_failure=False) - assert chat_message.tool_call_results[0].result == '{"weather": "sunny", "temp": "25"}' + assert chat_message.tool_call_results[0].result == json.dumps({output_key: "sunny", "temp": "25"}) def test_output_handler_failure_falls_back_to_string(self): weather_tool = Tool( diff --git a/test/tools/test_tool.py b/test/tools/test_tool.py index ee57c91297..d1c2c84a1f 100644 --- a/test/tools/test_tool.py +++ b/test/tools/test_tool.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +import json import re import threading from typing import Any @@ -215,6 +216,21 @@ def test_from_dict(self): assert tool.inputs_from_state == {"location": "city"} assert tool.outputs_to_state == {"documents": {"source": "docs", "handler": get_weather_report}} + @pytest.mark.parametrize("output_key", ["source", "handler", "raw_result"]) + def test_outputs_to_string_reserved_output_names_round_trip(self, output_key): + config = {output_key: {"source": "report", "handler": format_string}, "temp": {"source": "temperature"}} + tool = Tool( + name="weather", + description="Get weather report", + parameters=parameters, + function=get_weather_report, + outputs_to_string=config, + ) + + restored_tool = Tool.from_dict(json.loads(json.dumps(tool.to_dict()))) + + assert restored_tool.outputs_to_string == config + def test_serialize_outputs_to_string(self): config = {"handler": format_string, "source": "result", "raw_result": False} serialized = _serialize_outputs_to_string(config)