Skip to content
Merged
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
7 changes: 7 additions & 0 deletions haystack/components/tools/tool_invoker.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,13 @@ def _merge_tool_outputs(tool: Tool, result: Any, state: State) -> None:
for state_key, config in tool.outputs_to_state.items():
# Get the source key from the output config, otherwise use the entire result
source_key = config.get("source", None)

# If a source key is specified but absent from the result, skip this mapping to avoid passing a None value
# to state. This can be a common scenario with PipelineTool wrapping a pipeline that has conditional
# branches where not all outputs are always produced even if defined in outputs_to_state.
if source_key and source_key not in result:
continue

output_value = result.get(source_key) if source_key else result

# Merge other outputs into the state
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
fixes:
- |
Fix ``ToolInvoker._merge_tool_outputs`` silently appending ``None`` to list-typed state when a
tool's ``outputs_to_state`` source key is absent from the tool result. This is a common scenario
with ``PipelineTool`` wrapping a pipeline that has conditional branches where not all outputs are
always produced even if defined in ``outputs_to_state``. The mapping is now skipped entirely when
the source key is not present in the result dict.
26 changes: 25 additions & 1 deletion test/components/tools/test_tool_invoker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import pytest

from haystack import Pipeline
from haystack import Document, Pipeline
from haystack.components.agents.state import State
from haystack.components.builders.prompt_builder import PromptBuilder
from haystack.components.generators.chat.openai import OpenAIChatGenerator
Expand Down Expand Up @@ -1062,6 +1062,30 @@ def test_merge_tool_outputs_with_output_mapping_2(self):
)
assert state.data == {"all_weather_results": {"weather": "sunny", "temperature": 14, "unit": "celsius"}}

def test_merge_tool_outputs_source_key_absent_does_not_corrupt_list_state(self):
"""
Simulates a PipelineTool wrapping a pipeline with a conditional branch that may not execute, resulting in the
source key being absent from the tool result. The test verifies that in this case, the existing list in state
is not corrupted by appending None.
"""
tool = Tool(
name="retrieval",
description="mock",
parameters={"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
function=lambda query: {},
outputs_to_state={"documents": {"source": "documents_output"}},
)
invoker = ToolInvoker(tools=[tool])
existing_doc = Document(content="from first call")
state = State(schema={"documents": {"type": list[Document]}})
state.set("documents", [existing_doc])

# Tool result where the source key is absent (document extraction branch did not execute)
invoker._merge_tool_outputs(tool=tool, result={"result": "no web results found"}, state=state)

assert state.data["documents"] == [existing_doc]
assert None not in state.data["documents"]

def test_merge_tool_outputs_with_output_mapping_and_handler(self):
handler = lambda _, new: f"{new}" # noqa: E731
weather_tool = Tool(
Expand Down
Loading