Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion haystack/components/agents/tool_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)

Expand Down
33 changes: 19 additions & 14 deletions haystack/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -425,14 +423,21 @@ 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.

: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)

Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions test/components/agents/test_tool_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions test/tools/test_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
# SPDX-License-Identifier: Apache-2.0

import json
import re
import threading
from typing import Any
Expand Down Expand Up @@ -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)
Expand Down