Coverage for haystack/hooks/from_function.py: 100%
45 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import inspect
6from collections.abc import Awaitable, Callable
7from typing import Any, cast
9from haystack.components.agents.state.state import State
10from haystack.core.serialization import default_from_dict, default_to_dict
11from haystack.core.type_utils import _resolve_parameter_types
12from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
15def _takes_single_state_argument(function: Callable) -> bool:
16 """
17 Return whether `function` takes exactly one parameter annotated with `State`.
19 :param function: The callable to inspect.
20 :returns: True if the signature is a single `State`-annotated parameter, False otherwise.
21 """
22 param_types = list(_resolve_parameter_types(function).values())
23 return len(param_types) == 1 and param_types[0] is State
26class FunctionHook:
27 """
28 Wraps a function (or a sync/async pair) into a serializable `Hook`.
30 Produced by the `@hook` decorator for the single-function case. To give a hook both an optimized sync and async
31 path, construct it directly with both `function` and `async_function` set.
32 """
34 def __init__(
35 self,
36 function: Callable[[State], None] | None = None,
37 async_function: Callable[[State], Awaitable[None]] | None = None,
38 ) -> None:
39 """
40 Initialize the hook with a synchronous function, an async function, or both.
42 :param function: The synchronous function invoked by `run`. Must be a regular function — coroutine functions
43 should be passed to `async_function` instead. Either `function` or `async_function` (or both) must be set.
44 :param async_function: Optional coroutine function awaited by `run_async`. When only `async_function` is set,
45 `run` raises a `RuntimeError`. When only `function` is set, `run_async` calls `function`.
46 :raises ValueError: If neither is set, if `function` is a coroutine function, if `async_function` is not, or
47 if a provided function does not declare a `State`-typed parameter.
48 """
49 if function is None and async_function is None:
50 raise ValueError("A FunctionHook requires at least one of `function` or `async_function` to be set.")
51 if function is not None and inspect.iscoroutinefunction(function):
52 raise ValueError(
53 f"`function` must be a synchronous function. '{function.__name__}' is a coroutine function. "
54 "Pass it as `async_function` instead."
55 )
56 if async_function is not None and not inspect.iscoroutinefunction(async_function):
57 raise ValueError(
58 f"`async_function` must be a coroutine function defined with `async def`. "
59 f"Got '{getattr(async_function, '__name__', repr(async_function))}'."
60 )
61 for func in (function, async_function):
62 if func is not None and not _takes_single_state_argument(func):
63 raise ValueError(
64 f"Hook function '{func.__name__}' must take a single parameter annotated with `State` "
65 "(e.g. `def my_hook(state: State) -> None`)."
66 )
67 self.function = function
68 self.async_function = async_function
70 def run(self, state: State) -> None:
71 """
72 Run the synchronous function against the live `State`.
74 :param state: The Agent's live `State`, mutated in place by the wrapped function.
75 :raises RuntimeError: If the hook only has an `async_function`; use the Agent's async run methods instead.
76 """
77 if self.function is None:
78 raise RuntimeError(
79 "This FunctionHook only has an `async_function` and cannot run in a synchronous Agent run. "
80 "Use the Agent's async run methods, or provide a synchronous `function`."
81 )
82 self.function(state)
84 async def run_async(self, state: State) -> None:
85 """
86 Await the async function if set, otherwise call the synchronous function.
88 :param state: The Agent's live `State`, mutated in place by the wrapped function.
89 """
90 if self.async_function is not None:
91 await self.async_function(state)
92 else:
93 self.function(state) # type: ignore[misc] # guaranteed non-None: at least one is always set
95 def to_dict(self) -> dict[str, Any]:
96 """
97 Serialize the hook, storing each wrapped function as an importable reference.
99 :returns: A dictionary with the hook's type and the import paths of its sync/async functions.
100 """
101 return default_to_dict(
102 self,
103 function=serialize_callable(self.function) if self.function is not None else None,
104 async_function=serialize_callable(self.async_function) if self.async_function is not None else None,
105 )
107 @classmethod
108 def from_dict(cls, data: dict[str, Any]) -> "FunctionHook":
109 """
110 Deserialize the hook, resolving each function from its importable reference.
112 :param data: The serialized hook dictionary produced by `to_dict`.
113 :returns: The reconstructed `FunctionHook`.
114 """
115 init_params = data.get("init_parameters", {})
116 if init_params.get("function") is not None:
117 init_params["function"] = deserialize_callable(init_params["function"])
118 if init_params.get("async_function") is not None:
119 init_params["async_function"] = deserialize_callable(init_params["async_function"])
120 return default_from_dict(cls, data)
123def hook(function: Callable[[State], None | Awaitable[None]]) -> FunctionHook:
124 """
125 Wrap a function into a `Hook` the Agent can invoke during its run loop.
127 The decorated function receives the Agent's `State` and influences the run by mutating it in place. A coroutine
128 function is wrapped as the hook's async path; a regular function as its sync path. To give a single hook both
129 paths, construct a `FunctionHook` directly with both `function` and `async_function`.
131 ### Usage example
133 ```python
134 from haystack.components.agents import Agent
135 from haystack.components.generators.chat import OpenAIChatGenerator
136 from haystack.hooks import hook
137 from haystack.components.agents.state import State
138 from haystack.dataclasses import ChatMessage
139 from haystack.tools import tool
141 @tool
142 def weather_tool(city: str) -> str:
143 '''Get the current weather for a given city.'''
144 return f"The weather in {city} is sunny."
146 @tool
147 def save(content: str) -> str:
148 '''Save content to durable storage.'''
149 return "Saved."
151 @hook
152 def require_save(state: State) -> None:
153 if state.get("tool_call_counts", {}).get("save", 0) == 0:
154 state.set("messages", [ChatMessage.from_system("You must call `save` before finishing.")])
155 state.set("continue_run", True)
157 agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool, save], hooks={"on_exit": [require_save]})
158 ```
160 :param function: A callable taking the Agent's `State` and returning `None` (sync or async).
161 :returns: A `FunctionHook` wrapping the function.
162 """
163 # `iscoroutinefunction` narrows which slot the callable belongs in; cast to satisfy the typed slots.
164 if inspect.iscoroutinefunction(function):
165 return FunctionHook(async_function=cast("Callable[[State], Awaitable[None]]", function))
166 return FunctionHook(function=cast("Callable[[State], None]", function))