From 6e45e9cc3eaa543aebcbb48c181b95d3a4dc4564 Mon Sep 17 00:00:00 2001 From: tstadel <60758086+tstadel@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:01:55 +0100 Subject: [PATCH] enhancement: support multiple Tool string outputs --- haystack/components/tools/tool_invoker.py | 35 ++++++++++++------- haystack/tools/tool.py | 16 +++++++++ ...-tool-string-outputs-85716861ccecc34e.yaml | 12 +++++++ test/components/tools/test_tool_invoker.py | 19 ++++++++++ test/tools/test_tool.py | 20 +++++++++++ 5 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 releasenotes/notes/support-multiple-tool-string-outputs-85716861ccecc34e.yaml diff --git a/haystack/components/tools/tool_invoker.py b/haystack/components/tools/tool_invoker.py index 1c5aa6a98..c78a833a5 100644 --- a/haystack/components/tools/tool_invoker.py +++ b/haystack/components/tools/tool_invoker.py @@ -313,24 +313,35 @@ class ToolInvoker: and `raise_on_failure` is True. """ outputs_config = tool_to_invoke.outputs_to_string or {} - source_key = outputs_config.get("source") - - # If no handler is provided, we use the default handler - output_to_string_handler = outputs_config.get("handler", self._default_output_to_string_handler) - - # If a source key is provided, we extract the result from the source key - result_to_convert = result.get(source_key) if source_key is not None else result - try: - tool_result_str = output_to_string_handler(result_to_convert) - chat_message = ChatMessage.from_tool(tool_result=tool_result_str, origin=tool_call) + # Root level single output configuration + if not outputs_config or "source" in outputs_config or "handler" in outputs_config: + source_key = outputs_config.get("source", None) + # If a source key is provided, we extract the result from the source key + value = result.get(source_key) if source_key is not None else result + # If no handler is provided, we use the default handler + output_to_string_handler = outputs_config.get("handler", self._default_output_to_string_handler) + tool_result_str = output_to_string_handler(value) + return ChatMessage.from_tool(tool_result=tool_result_str, origin=tool_call) + + # Multiple outputs configuration + tool_result = {} + for output_key, config in outputs_config.items(): + # If no source key is provided, we use the output key itself + source_key = config.get("source", output_key) + value = result[source_key] + # If no handler is provided, we use the default handler + output_to_string_handler = config.get("handler", self._default_output_to_string_handler) + key_result_str = output_to_string_handler(value) + tool_result[output_key] = key_result_str + tool_result_str = self._default_output_to_string_handler(tool_result) + return ChatMessage.from_tool(tool_result=tool_result_str, origin=tool_call) except Exception as e: error = StringConversionError(tool_call.tool_name, output_to_string_handler.__name__, e) if self.raise_on_failure: raise error from e logger.error("{error_exception}", error_exception=error) - chat_message = ChatMessage.from_tool(tool_result=str(error), origin=tool_call, error=True) - return chat_message + return ChatMessage.from_tool(tool_result=str(error), origin=tool_call, error=True) @staticmethod def _get_func_params(tool: Tool) -> set: diff --git a/haystack/tools/tool.py b/haystack/tools/tool.py index f46194860..a75d82424 100644 --- a/haystack/tools/tool.py +++ b/haystack/tools/tool.py @@ -105,6 +105,22 @@ class Tool: 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 "source" in self.outputs_to_string or "handler" in self.outputs_to_string: + for key in self.outputs_to_string: + if key not in {"source", "handler"}: + raise ValueError( + "Invalid outputs_to_string config. " + "When using 'source' or 'handler' at the root level, no other keys are allowed. " + "Use individual output configs instead." + ) + else: + for key, config in self.outputs_to_string.items(): + if not isinstance(config, dict): + raise ValueError(f"outputs_to_string configuration for key '{key}' must be a dictionary") + if "source" in config and not isinstance(config["source"], str): + raise ValueError(f"outputs_to_string source for key '{key}' must be a string.") + if "handler" in config and not callable(config["handler"]): + raise ValueError(f"outputs_to_string handler for key '{key}' must be callable") @property def tool_spec(self) -> dict[str, Any]: diff --git a/releasenotes/notes/support-multiple-tool-string-outputs-85716861ccecc34e.yaml b/releasenotes/notes/support-multiple-tool-string-outputs-85716861ccecc34e.yaml new file mode 100644 index 000000000..f0e3022b8 --- /dev/null +++ b/releasenotes/notes/support-multiple-tool-string-outputs-85716861ccecc34e.yaml @@ -0,0 +1,12 @@ +--- +enhancements: + - | + Support Multiple Tool String Outputs + + Added support for tools to define multiple string outputs using the `outputs_to_string` configuration. + This allows users to specify how different parts of a tool's output should be converted to strings, + enhancing flexibility in handling tool results. + + - Updated `ToolInvoker` to handle multiple output configurations. + - Updated `Tool` to validate and store multiple output configurations. + - Added tests to verify the functionality of multiple string outputs. diff --git a/test/components/tools/test_tool_invoker.py b/test/components/tools/test_tool_invoker.py index ee457169a..7e0c4df65 100644 --- a/test/components/tools/test_tool_invoker.py +++ b/test/components/tools/test_tool_invoker.py @@ -784,6 +784,25 @@ class TestToolInvokerErrorHandling: 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): + weather_tool = Tool( + name="weather_tool", + description="Provides weather information for a given location.", + parameters=weather_parameters, + function=weather_function, + # Pass custom handler that will throw an error when trying to convert tool_result + outputs_to_string={"weather": {"source": "weather"}, "temp": {"source": "temperature"}}, + ) + invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=True) + + tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}) + + tool_result = {"weather": "sunny", "temperature": 25, "unit": "celsius"} + chat_message = invoker._prepare_tool_result_message( + result=tool_result, tool_call=tool_call, tool_to_invoke=weather_tool + ) + assert chat_message.tool_call_results[0].result == "{'weather': 'sunny', 'temp': '25'}" + def test_string_conversion_error(self): weather_tool = Tool( name="weather_tool", diff --git a/test/tools/test_tool.py b/test/tools/test_tool.py index 0725274fc..83d91e8a3 100644 --- a/test/tools/test_tool.py +++ b/test/tools/test_tool.py @@ -65,6 +65,26 @@ class TestTool: outputs_to_state=outputs_to_state, ) + @pytest.mark.parametrize( + "outputs_to_string", + [ + pytest.param({"source": get_weather_report}, id="source-not-a-string"), + pytest.param({"handler": "some_string"}, id="handler-not-callable"), + pytest.param({"documents": ["some_value"]}, id="multi-value-config-not-a-dict"), + pytest.param({"documents": {"source": get_weather_report}}, id="multi-value-source-not-a-string"), + pytest.param({"documents": {"handler": "some_string"}}, id="multi-value-handler-not-callable"), + ], + ) + def test_init_invalid_output_to_string_structure(self, outputs_to_string): + with pytest.raises(ValueError): + Tool( + name="irrelevant", + description="irrelevant", + parameters={"type": "object", "properties": {"city": {"type": "string"}}}, + function=get_weather_report, + outputs_to_string=outputs_to_string, + ) + def test_tool_spec(self): tool = Tool( name="weather", description="Get weather report", parameters=parameters, function=get_weather_report