diff --git a/haystack/tools/tool.py b/haystack/tools/tool.py index 8ac886482e8..9df8005f64d 100644 --- a/haystack/tools/tool.py +++ b/haystack/tools/tool.py @@ -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}." diff --git a/releasenotes/notes/tool-empty-inputs-from-state-validation-bc8460226991e1ba.yaml b/releasenotes/notes/tool-empty-inputs-from-state-validation-bc8460226991e1ba.yaml new file mode 100644 index 00000000000..8adfe7ce842 --- /dev/null +++ b/releasenotes/notes/tool-empty-inputs-from-state-validation-bc8460226991e1ba.yaml @@ -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. diff --git a/test/tools/test_component_tool.py b/test/tools/test_component_tool.py index ea4199476b1..d9c300f57f2 100644 --- a/test/tools/test_component_tool.py +++ b/test/tools/test_component_tool.py @@ -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. @@ -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"}} diff --git a/test/tools/test_tool.py b/test/tools/test_tool.py index b0acf18f5bc..388c19404d0 100644 --- a/test/tools/test_tool.py +++ b/test/tools/test_tool.py @@ -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")):