Skip to content

Commit c42f9bc

Browse files
authored
fix: deferred annotation pickup in Agent (#12185)
1 parent b6a8210 commit c42f9bc

8 files changed

Lines changed: 113 additions & 29 deletions

File tree

haystack/components/agents/tool_calling.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import asyncio
66
import contextvars
7-
import inspect
87
import json
98
from collections.abc import Callable
109
from concurrent.futures import ThreadPoolExecutor
@@ -13,6 +12,7 @@
1312
from haystack import logging, tracing
1413
from haystack.components.agents.state.state import State
1514
from haystack.core.component.sockets import Sockets
15+
from haystack.core.type_utils import _resolve_parameter_types
1616
from haystack.dataclasses import ChatMessage, ToolCall
1717
from haystack.dataclasses.streaming_chunk import StreamingCallbackT, StreamingChunk, _invoke_streaming_callback
1818
from haystack.tools import ComponentTool, Tool, ToolsType, _check_duplicate_tool_names, flatten_tools_or_toolsets
@@ -271,7 +271,7 @@ def _get_func_params(tool: Tool) -> dict[str, Any]:
271271
return {name: socket.type for name, socket in tool._component.__haystack_input__._sockets_dict.items()}
272272
# Tool.__post_init__ guarantees that at least one of `function` / `async_function` is set.
273273
target = tool.function if tool.function is not None else tool.async_function
274-
return {name: param.annotation for name, param in inspect.signature(target).parameters.items()} # type: ignore[arg-type]
274+
return _resolve_parameter_types(target) # type: ignore[arg-type]
275275

276276

277277
def _inject_state_args(tool: Tool, llm_args: dict[str, Any], state: State) -> dict[str, Any]:

haystack/core/component/component.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@
7474
"""
7575

7676
import inspect
77-
import typing
7877
from collections.abc import Callable, Coroutine, Iterator, Mapping
7978
from contextlib import contextmanager
8079
from contextvars import ContextVar
@@ -85,6 +84,7 @@
8584

8685
from haystack import logging
8786
from haystack.core.errors import ComponentError
87+
from haystack.core.type_utils import _resolve_parameter_types
8888

8989
from .sockets import Sockets
9090
from .types import InputSocket, OutputSocket, _empty
@@ -234,24 +234,15 @@ def inner(method: Callable[..., Any], sockets: Sockets) -> inspect.Signature:
234234
from inspect import Parameter
235235

236236
run_signature = inspect.signature(method)
237-
try:
238-
# TypeError is raised if the argument is not of a type that can contain annotations
239-
run_hints = typing.get_type_hints(method)
240-
except TypeError:
241-
run_hints = None
237+
# Resolves the annotations of components using postponed evaluation of annotations, where they are stored
238+
# as strings.
239+
param_types = _resolve_parameter_types(method)
242240

243241
for param_name, param_info in run_signature.parameters.items():
244242
if param_name == "self" or param_info.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD):
245243
continue
246244

247-
# We prefer the type annotation from inspect.signature, but if it's a string we need to resolve it
248-
# using the hints. The type annotation can be a string if the component is using postponed evaluation
249-
# of annotations.
250-
annotation = param_info.annotation
251-
if isinstance(annotation, str) and run_hints is not None:
252-
annotation = run_hints.get(param_name, annotation)
253-
254-
socket_kwargs = {"name": param_name, "type": annotation}
245+
socket_kwargs = {"name": param_name, "type": param_types[param_name]}
255246
if param_info.default != Parameter.empty:
256247
socket_kwargs["default_value"] = param_info.default
257248

haystack/core/type_utils.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
import collections.abc
6+
import inspect
7+
from collections.abc import Callable
68
from enum import Enum
79
from types import NoneType, UnionType
8-
from typing import Any, Union, get_args, get_origin
10+
from typing import Any, Union, get_args, get_origin, get_type_hints
911

1012
from haystack.dataclasses import ChatMessage
1113

@@ -28,6 +30,36 @@ class ConversionStrategy(Enum):
2830
ConversionStrategyType = ConversionStrategy | None
2931

3032

33+
def _resolve_parameter_types(target: Callable) -> dict[str, Any]:
34+
"""
35+
Map the parameter names of a callable to their type annotations, resolving postponed annotations.
36+
37+
A callable defined in a module using `from __future__ import annotations` stores its annotations as strings, which
38+
never match the types they refer to. Only string annotations are looked up in the resolved type hints: for the
39+
others the annotation from the signature is kept.
40+
41+
:param target: The callable to inspect.
42+
:returns: A dict mapping parameter names to their type annotations. Annotations that cannot be resolved, and
43+
parameters without an annotation, are returned as they appear in the signature.
44+
"""
45+
parameters = inspect.signature(target).parameters
46+
if any(isinstance(param.annotation, str) for param in parameters.values()):
47+
try:
48+
hints = get_type_hints(target)
49+
except Exception:
50+
# TypeError is raised for objects that cannot carry annotations, NameError for names that are not
51+
# importable at runtime. Either way we fall back to the unresolved annotations.
52+
hints = {}
53+
# Non-string annotations are kept as they are written: on Python 3.10 `get_type_hints` widens the annotation
54+
# of a parameter defaulting to `None` into an optional. This was changed in Python 3.11, see
55+
# https://docs.python.org/3/whatsnew/3.11.html#typing.
56+
return {
57+
name: hints.get(name, param.annotation) if isinstance(param.annotation, str) else param.annotation
58+
for name, param in parameters.items()
59+
}
60+
return {name: param.annotation for name, param in parameters.items()}
61+
62+
3163
def _type_name(type_: Any) -> str:
3264
"""
3365
Util methods to get a nice readable representation of a type.

haystack/hooks/from_function.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
import inspect
66
from collections.abc import Awaitable, Callable
7-
from typing import Any, cast, get_type_hints
7+
from typing import Any, cast
88

99
from haystack.components.agents.state.state import State
1010
from haystack.core.serialization import default_from_dict, default_to_dict
11+
from haystack.core.type_utils import _resolve_parameter_types
1112
from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
1213

1314

@@ -18,17 +19,8 @@ def _takes_single_state_argument(function: Callable) -> bool:
1819
:param function: The callable to inspect.
1920
:returns: True if the signature is a single `State`-annotated parameter, False otherwise.
2021
"""
21-
params = list(inspect.signature(function).parameters.values())
22-
if len(params) != 1:
23-
return False
24-
25-
try:
26-
# get_type_hints resolves postponed annotations, where `State` is stored as a string. If resolution fails, fall
27-
# back to the raw annotation so validation still raises the ValueError in FunctionHook.
28-
annotation = get_type_hints(function).get(params[0].name, params[0].annotation)
29-
except (NameError, TypeError, AttributeError):
30-
annotation = params[0].annotation
31-
return annotation is State
22+
param_types = list(_resolve_parameter_types(function).values())
23+
return len(param_types) == 1 and param_types[0] is State
3224

3325

3426
class FunctionHook:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
fixes:
3+
- |
4+
Tool functions defined in a module using ``from __future__ import annotations`` are now inspected correctly by
5+
``Agent``. Postponed annotations are stored as strings, so a parameter annotated with ``State`` was not recognized
6+
and the live ``State`` object was not injected into the tool call. The annotations are now resolved before they
7+
are inspected.

test/components/agents/test_tool_calling.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ async def async_weather_function(location):
5454
return weather_function(location)
5555

5656

57+
def weather_with_postponed_state_annotation(city: str, state: "State") -> str:
58+
return f"Weather in {city}"
59+
60+
5761
weather_parameters = {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}
5862

5963

@@ -226,6 +230,18 @@ def function_with_optional_state(city: str, state: State | None = None) -> str:
226230
assert args["city"] == "Paris"
227231
assert args["state"] is state
228232

233+
def test_inject_state_args_injects_state_object_for_postponed_state_annotation(self):
234+
state_tool = Tool(
235+
name="state_tool",
236+
description="A tool whose annotations are postponed.",
237+
parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
238+
function=weather_with_postponed_state_annotation,
239+
)
240+
state = State(schema={"city": {"type": str}}, data={"city": "Berlin"})
241+
args = _inject_state_args(tool=state_tool, llm_args={"city": "Paris"}, state=state)
242+
assert args["city"] == "Paris"
243+
assert args["state"] is state
244+
229245

230246
class TestRunTool:
231247
def test_run_with_streaming_callback_finish_reason(self, weather_tool):
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
# The functions below must live in a module of their own so that `from __future__ import annotations` applies to them
6+
# and their annotations are stored as strings (postponed evaluation).
7+
from __future__ import annotations
8+
9+
from haystack.dataclasses import Document
10+
11+
12+
def retrieve(query: str, documents: list[Document], top_k: int | None = None) -> list[Document]:
13+
return documents[:top_k]

test/core/test_type_utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import sys
66
from enum import Enum
7+
from functools import partial
8+
from inspect import Parameter
79
from pathlib import Path
810
from typing import Any, Callable, Dict, Iterable, List, Literal, Mapping, Optional, Sequence, Set, Tuple, Union
911

@@ -16,10 +18,12 @@
1618
_contains_type,
1719
_convert_value,
1820
_get_first_item,
21+
_resolve_parameter_types,
1922
_type_name,
2023
_types_are_compatible,
2124
)
2225
from haystack.dataclasses import ByteStream, ChatMessage, Document, GeneratedAnswer
26+
from test.core.future_annotations_functions import retrieve
2327

2428

2529
class Class1:
@@ -1165,3 +1169,32 @@ def test_union_in_sender_problem(self):
11651169
is_compatible, strategy = _types_are_compatible(sender=str | ChatMessage, receiver=str)
11661170
assert not is_compatible
11671171
assert strategy is None
1172+
1173+
1174+
def unresolvable_annotation(document: "Unimportable") -> None: ... # type: ignore[name-defined] # noqa: F821
1175+
1176+
1177+
class TestResolveParameterTypes:
1178+
def test_resolves_postponed_annotations(self):
1179+
assert _resolve_parameter_types(retrieve) == {"query": str, "documents": list[Document], "top_k": Optional[int]}
1180+
1181+
def test_keeps_eagerly_evaluated_annotations(self):
1182+
# `top_k` keeps the annotation it was written with, it is not widened to `int | None` because of its default.
1183+
def function(documents: list[Document], top_k: int = None) -> None: ... # type: ignore[assignment]
1184+
1185+
assert _resolve_parameter_types(function) == {"documents": list[Document], "top_k": int}
1186+
1187+
def test_includes_parameters_without_annotation(self):
1188+
def function(query, top_k: int) -> None: ... # type: ignore[no-untyped-def]
1189+
1190+
assert _resolve_parameter_types(function) == {"query": Parameter.empty, "top_k": int}
1191+
1192+
def test_keeps_unresolvable_annotation_as_string(self):
1193+
assert _resolve_parameter_types(unresolvable_annotation) == {"document": "Unimportable"}
1194+
1195+
def test_keeps_annotations_of_target_that_cannot_carry_them(self):
1196+
# A partial is not a module, class, method or function, so its annotations cannot be resolved at all.
1197+
assert _resolve_parameter_types(partial(retrieve, "query")) == {
1198+
"documents": "list[Document]",
1199+
"top_k": "int | None",
1200+
}

0 commit comments

Comments
 (0)