Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

163 lines
7.5 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import inspect
from collections.abc import Awaitable, Callable
from typing import Any, cast, get_type_hints
from haystack.components.agents.state.state import State
from haystack.core.serialization import default_from_dict, default_to_dict
from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
def _takes_single_state_argument(function: Callable) -> bool:
"""
Return whether `function` takes exactly one parameter annotated with `State`.
:param function: The callable to inspect.
:returns: True if the signature is a single `State`-annotated parameter, False otherwise.
"""
params = list(inspect.signature(function).parameters.values())
if len(params) != 1:
return False
try:
# get_type_hints resolves postponed annotations, where `State` is stored as a string. If resolution fails, fall
# back to the raw annotation so validation still raises the ValueError in FunctionHook.
annotation = get_type_hints(function).get(params[0].name, params[0].annotation)
except (NameError, TypeError, AttributeError):
annotation = params[0].annotation
return annotation is State
class FunctionHook:
"""
Wraps a function (or a sync/async pair) into a serializable `Hook`.
Produced by the `@hook` decorator for the single-function case. To give a hook both an optimized sync and async
path, construct it directly with both `function` and `async_function` set.
"""
def __init__(
self,
function: Callable[[State], None] | None = None,
async_function: Callable[[State], Awaitable[None]] | None = None,
) -> None:
"""
Initialize the hook with a synchronous function, an async function, or both.
:param function: The synchronous function invoked by `run`. Must be a regular function — coroutine functions
should be passed to `async_function` instead. Either `function` or `async_function` (or both) must be set.
:param async_function: Optional coroutine function awaited by `run_async`. When only `async_function` is set,
`run` raises a `RuntimeError`. When only `function` is set, `run_async` calls `function`.
:raises ValueError: If neither is set, if `function` is a coroutine function, if `async_function` is not, or
if a provided function does not declare a `State`-typed parameter.
"""
if function is None and async_function is None:
raise ValueError("A FunctionHook requires at least one of `function` or `async_function` to be set.")
if function is not None and inspect.iscoroutinefunction(function):
raise ValueError(
f"`function` must be a synchronous function. '{function.__name__}' is a coroutine function. "
"Pass it as `async_function` instead."
)
if async_function is not None and not inspect.iscoroutinefunction(async_function):
raise ValueError(
f"`async_function` must be a coroutine function defined with `async def`. "
f"Got '{getattr(async_function, '__name__', repr(async_function))}'."
)
for func in (function, async_function):
if func is not None and not _takes_single_state_argument(func):
raise ValueError(
f"Hook function '{func.__name__}' must take a single parameter annotated with `State` "
"(e.g. `def my_hook(state: State) -> None`)."
)
self.function = function
self.async_function = async_function
def run(self, state: State) -> None:
"""
Run the synchronous function against the live `State`.
:param state: The Agent's live `State`, mutated in place by the wrapped function.
:raises RuntimeError: If the hook only has an `async_function`; use the Agent's async run methods instead.
"""
if self.function is None:
raise RuntimeError(
"This FunctionHook only has an `async_function` and cannot run in a synchronous Agent run. "
"Use the Agent's async run methods, or provide a synchronous `function`."
)
self.function(state)
async def run_async(self, state: State) -> None:
"""
Await the async function if set, otherwise call the synchronous function.
:param state: The Agent's live `State`, mutated in place by the wrapped function.
"""
if self.async_function is not None:
await self.async_function(state)
else:
self.function(state) # type: ignore[misc] # guaranteed non-None: at least one is always set
def to_dict(self) -> dict[str, Any]:
"""
Serialize the hook, storing each wrapped function as an importable reference.
:returns: A dictionary with the hook's type and the import paths of its sync/async functions.
"""
return default_to_dict(
self,
function=serialize_callable(self.function) if self.function is not None else None,
async_function=serialize_callable(self.async_function) if self.async_function is not None else None,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "FunctionHook":
"""
Deserialize the hook, resolving each function from its importable reference.
:param data: The serialized hook dictionary produced by `to_dict`.
:returns: The reconstructed `FunctionHook`.
"""
init_params = data.get("init_parameters", {})
if init_params.get("function") is not None:
init_params["function"] = deserialize_callable(init_params["function"])
if init_params.get("async_function") is not None:
init_params["async_function"] = deserialize_callable(init_params["async_function"])
return default_from_dict(cls, data)
def hook(function: Callable[[State], None | Awaitable[None]]) -> FunctionHook:
"""
Wrap a function into a `Hook` the Agent can invoke during its run loop.
The decorated function receives the Agent's `State` and influences the run by mutating it in place. A coroutine
function is wrapped as the hook's async path; a regular function as its sync path. To give a single hook both
paths, construct a `FunctionHook` directly with both `function` and `async_function`.
### Usage example
```python
from haystack.components.agents import Agent
from haystack.hooks import hook
from haystack.components.agents.state import State
from haystack.dataclasses import ChatMessage
@hook
def require_save(state: State) -> None:
if state.get("tool_call_counts", {}).get("save", 0) == 0:
state.set("messages", [ChatMessage.from_system("You must call `save` before finishing.")])
state.set("continue_run", True)
agent = Agent(chat_generator=..., tools=[...], hooks={"on_exit": [require_save]})
```
:param function: A callable taking the Agent's `State` and returning `None` (sync or async).
:returns: A `FunctionHook` wrapping the function.
"""
# `iscoroutinefunction` narrows which slot the callable belongs in; cast to satisfy the typed slots.
if inspect.iscoroutinefunction(function):
return FunctionHook(async_function=cast("Callable[[State], Awaitable[None]]", function))
return FunctionHook(function=cast("Callable[[State], None]", function))