Skip to content
Closed
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
2 changes: 1 addition & 1 deletion haystack/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def __post_init__(self) -> None: # noqa: C901, PLR0912
f"inputs_from_state values must be str, not {type(param_name).__name__}. "
f"Got {param_name!r} for key '{state_key}'."
)
if valid_inputs and param_name not in valid_inputs:
if param_name not in valid_inputs:
raise ValueError(
f"inputs_from_state maps '{state_key}' to unknown parameter '{param_name}'. "
f"Valid parameters are: {valid_inputs}."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
fixes:
- |
``Tool`` now validates ``inputs_from_state`` against the tool's actual input parameters even when the tool takes no input
at all. Previously, mapping a state key to a non-existent parameter was accepted at construction time for tools with
an empty set of valid parameters (e.g. a function with no arguments, or a ``ComponentTool`` wrapping a component without
input sockets), and the typo only surfaced later as a ``ToolInvocationError`` when the agent invoked the tool.
20 changes: 20 additions & 0 deletions test/tools/test_component_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,18 @@ def run(self, documents: list[Document], top_k: int = 5) -> dict[str, str]:
return {"concatenated": "\n".join(doc.content for doc in documents[:top_k] if doc.content)}


@component
class NoInputComponent:
"""A component that takes no input at all."""

@component.output_types(reply=str)
def run(self) -> dict[str, str]:
"""
Returns a fixed reply.
"""
return {"reply": "Hello!"}


def output_handler(old, new):
"""
Output handler to test serialization.
Expand Down Expand Up @@ -194,6 +206,14 @@ def test_from_component_with_invalid_inputs_from_state_nested_dict(self):
with pytest.raises(TypeError, match="must be str, not dict"):
ComponentTool(component=SimpleComponent(), inputs_from_state={"documents": {"source": "documents"}}) # type: ignore[dict-item]

def test_from_component_with_inputs_from_state_and_no_input_sockets(self):
"""A component without input sockets has no valid mapping target, so the typo must not pass silently."""
tool = ComponentTool(component=NoInputComponent())
assert tool.parameters == {"type": "object", "properties": {}}

with pytest.raises(ValueError, match="unknown parameter 'text'. Valid parameters are: set\\(\\)\\."):
ComponentTool(component=NoInputComponent(), inputs_from_state={"state_text": "text"})

def test_from_component_with_outputs_to_state(self):
tool = ComponentTool(component=SimpleComponent(), outputs_to_state={"replies": {"source": "reply"}})
assert tool.outputs_to_state == {"replies": {"source": "reply"}}
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 @@ -296,6 +296,22 @@ def test_inputs_from_state_validation_with_invalid_parameter(self):
inputs_from_state={"state_key": "nonexistent"},
)

def test_inputs_from_state_validation_with_no_valid_parameters(self):
"""A tool that takes no input has a known-empty set of valid parameters, so any mapping is a typo."""
with pytest.raises(
ValueError,
match=re.escape(
"inputs_from_state maps 'city' to unknown parameter 'nonexistent'. Valid parameters are: set()."
),
):
Tool(
name="clock",
description="What time is it?",
parameters={"type": "object", "properties": {}},
function=lambda: {"time": "12:00"},
inputs_from_state={"city": "nonexistent"},
)

def test_inputs_from_state_validation_with_non_string_value(self):
"""Test that inputs_from_state values must be strings"""
with pytest.raises(TypeError, match=re.escape("inputs_from_state values must be str, not dict")):
Expand Down