chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"protocol": ["Hook", "HookPoint", "BEFORE_LLM", "BEFORE_TOOL", "AFTER_TOOL", "ON_EXIT", "VALID_HOOK_POINTS"],
|
||||
"from_function": ["FunctionHook", "hook"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .from_function import FunctionHook as FunctionHook
|
||||
from .from_function import hook as hook
|
||||
from .protocol import AFTER_TOOL as AFTER_TOOL
|
||||
from .protocol import BEFORE_LLM as BEFORE_LLM
|
||||
from .protocol import BEFORE_TOOL as BEFORE_TOOL
|
||||
from .protocol import ON_EXIT as ON_EXIT
|
||||
from .protocol import VALID_HOOK_POINTS as VALID_HOOK_POINTS
|
||||
from .protocol import Hook as Hook
|
||||
from .protocol import HookPoint as HookPoint
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,162 @@
|
||||
# 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))
|
||||
@@ -0,0 +1,31 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from haystack.components.agents.state.state import State
|
||||
from haystack.hooks.protocol import Hook, HookPoint
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
|
||||
|
||||
def _run_hooks(hooks: dict[HookPoint, list[Hook]], hook_point: HookPoint, state: State) -> None:
|
||||
"""
|
||||
Run every hook registered for the given hook point, in list order.
|
||||
|
||||
:param hooks: Hooks keyed by hook point.
|
||||
:param hook_point: The hook point whose hooks to run; hooks registered under other hook points are skipped.
|
||||
:param state: The Agent's live `State`, passed to each hook and mutated in place.
|
||||
"""
|
||||
for h in hooks.get(hook_point, []):
|
||||
h.run(state)
|
||||
|
||||
|
||||
async def _run_hooks_async(hooks: dict[HookPoint, list[Hook]], hook_point: HookPoint, state: State) -> None:
|
||||
"""
|
||||
Run every hook for the given hook point, preferring `run_async` and offloading sync-only `run` hooks.
|
||||
|
||||
:param hooks: Hooks keyed by hook point.
|
||||
:param hook_point: The hook point whose hooks to run; hooks registered under other hook points are skipped.
|
||||
:param state: The Agent's live `State`, passed to each hook and mutated in place.
|
||||
"""
|
||||
for h in hooks.get(hook_point, []):
|
||||
await _execute_component_async(h, state=state)
|
||||
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any, Literal, Protocol, get_args
|
||||
|
||||
from haystack.components.agents.state.state import State
|
||||
|
||||
# Points in the Agent's run loop at which hooks can be registered.
|
||||
HookPoint = Literal["before_llm", "before_tool", "after_tool", "on_exit"]
|
||||
|
||||
BEFORE_LLM: HookPoint = "before_llm"
|
||||
BEFORE_TOOL: HookPoint = "before_tool"
|
||||
AFTER_TOOL: HookPoint = "after_tool"
|
||||
ON_EXIT: HookPoint = "on_exit"
|
||||
VALID_HOOK_POINTS: tuple[HookPoint, ...] = get_args(HookPoint)
|
||||
|
||||
|
||||
class Hook(Protocol):
|
||||
"""
|
||||
A callable the Agent invokes at a point in its run loop, receiving the live `State`.
|
||||
|
||||
A hook influences the run only by mutating `State` in place. At least `messages` (the conversation),
|
||||
`step_count`, `token_usage` and `tool_call_counts` are available; any additional keys defined in the Agent's
|
||||
`state_schema` are available too. The same hook object can be registered under multiple hook points.
|
||||
|
||||
Implement this protocol directly for stateful hooks (e.g. one wrapping a component), or use the `@hook` decorator to
|
||||
wrap a plain `(State) -> None` function.
|
||||
|
||||
A hook may additionally define `async def run_async(self, state: State) -> None` for true async behavior; when
|
||||
absent, the Agent calls `run` during async runs. It is left off this protocol on purpose so sync-only hooks
|
||||
don't have to implement it.
|
||||
|
||||
A hook may also implement the optional lifecycle methods `warm_up` / `warm_up_async` and `close` / `close_async`.
|
||||
The Agent calls them from its own `warm_up` / `warm_up_async` and `close` / `close_async`, so a hook can defer
|
||||
opening clients or reading credentials until warm-up and release them on close.
|
||||
"""
|
||||
|
||||
def run(self, state: State) -> None:
|
||||
"""Run the hook against the live `State`, mutating it in place."""
|
||||
...
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the hook to a dictionary."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Hook":
|
||||
"""Deserialize the hook from a dictionary."""
|
||||
...
|
||||
@@ -0,0 +1,27 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"hooks": ["ToolResultOffloadHook", "RESULT_STORE_CONTEXT_KEY"],
|
||||
"policies": ["AlwaysOffload", "NeverOffload", "OffloadOverChars"],
|
||||
"stores": ["FileSystemToolResultStore"],
|
||||
"types": ["OffloadPolicy", "ToolResultStore"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .hooks import RESULT_STORE_CONTEXT_KEY as RESULT_STORE_CONTEXT_KEY
|
||||
from .hooks import ToolResultOffloadHook as ToolResultOffloadHook
|
||||
from .policies import AlwaysOffload as AlwaysOffload
|
||||
from .policies import NeverOffload as NeverOffload
|
||||
from .policies import OffloadOverChars as OffloadOverChars
|
||||
from .stores import FileSystemToolResultStore as FileSystemToolResultStore
|
||||
from .types import OffloadPolicy as OffloadPolicy
|
||||
from .types import ToolResultStore as ToolResultStore
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,354 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from haystack import logging
|
||||
from haystack.components.agents.state.state import State
|
||||
from haystack.components.agents.state.state_utils import replace_values
|
||||
from haystack.core.serialization import default_from_dict, default_to_dict
|
||||
from haystack.dataclasses import ChatMessage, TextContent
|
||||
from haystack.dataclasses.chat_message import ToolCallResultContentT
|
||||
from haystack.hooks.tool_result_offloading.types import OffloadPolicy, ToolResultStore
|
||||
from haystack.utils.deserialization import deserialize_component_inplace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Meta key marking an already-offloaded tool-result message (its value is the store reference). The offloaded pointer
|
||||
# is itself a tool result in the trailing block the hook scans, so this marker stops a second offload hook registered
|
||||
# under `after_tool` from offloading the pointer text again and writing a junk file.
|
||||
_OFFLOADED_META_KEY = "tool_result_offloaded"
|
||||
|
||||
# Key under which a per-run store override may be supplied via the Agent's `hook_context` (e.g. a request-scoped
|
||||
# sandbox filesystem).
|
||||
RESULT_STORE_CONTEXT_KEY = "tool_result_store"
|
||||
|
||||
|
||||
def _result_store_key(tool_name: str, tool_call_id: str | None, step: int, index: int) -> str:
|
||||
"""
|
||||
Build a per-result store key that is stable and unique within a run.
|
||||
|
||||
Combining the step, tool name, and tool call id keeps results from different tools and different steps from
|
||||
colliding. When the tool call carries no id (it is optional and not every generator sets it), the result's
|
||||
position in the step's batch is used instead, so two id-less calls to the same tool in the same step do not
|
||||
collide.
|
||||
|
||||
:param tool_name: The name of the tool that produced the result.
|
||||
:param tool_call_id: The id of the originating tool call, or None when the call carried no id.
|
||||
:param step: The Agent's current step count.
|
||||
:param index: The result's position within this step's batch of tool results, used when `tool_call_id` is None.
|
||||
:returns: A file-name-like key for the store, e.g. `2_web_search_call-123.txt`.
|
||||
"""
|
||||
return f"{step}_{tool_name}_{tool_call_id or f'call{index}'}.txt"
|
||||
|
||||
|
||||
def _fresh_tool_results_start(messages: list[ChatMessage]) -> int:
|
||||
"""
|
||||
Return the index at which the trailing run of tool-result messages begins.
|
||||
|
||||
The Agent appends the current step's tool results to the end of the conversation, so the trailing contiguous
|
||||
block of tool-result messages is exactly the freshly produced batch; everything before it is history the hook
|
||||
must not touch (results from earlier steps or ones the caller passed in).
|
||||
|
||||
:param messages: The conversation, oldest to newest.
|
||||
:returns: The index of the first message in the trailing tool-result block, or `len(messages)` when the last
|
||||
message is not a tool result (no fresh results to offload).
|
||||
"""
|
||||
index = len(messages)
|
||||
while index > 0 and messages[index - 1].tool_call_result is not None:
|
||||
index -= 1
|
||||
return index
|
||||
|
||||
|
||||
def _offloadable_text(content: ToolCallResultContentT) -> str | None:
|
||||
"""
|
||||
Return the text of a tool result if it can be offloaded as text, otherwise None.
|
||||
|
||||
A plain string is returned as-is; a non-empty sequence made up entirely of `TextContent` blocks is concatenated
|
||||
into a single string. Anything else (e.g. a result containing image or file content) returns None and is left in
|
||||
context.
|
||||
|
||||
:param content: The tool result content to inspect.
|
||||
:returns: The offloadable text, or None when the content is not purely text.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
texts = [block.text for block in content if isinstance(block, TextContent)]
|
||||
if texts and len(texts) == len(content):
|
||||
return "".join(texts)
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_offload_strategies(strategies: dict[str | tuple[str, ...], OffloadPolicy]) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize an offload-strategies mapping to a plain, mapping-key-safe dictionary.
|
||||
|
||||
Mapping keys must be strings, so a tuple of tool names (one policy shared across several tools) is encoded as a
|
||||
JSON-array string (e.g. `("a", "b")` -> `'["a", "b"]'`); a single tool name or the `"*"` wildcard is kept as-is.
|
||||
Each policy is serialized via its own `to_dict`, which embeds its type so it can be reconstructed regardless of
|
||||
its concrete class.
|
||||
|
||||
:param strategies: Mapping of tool name (or a tuple of tool names, or `"*"`) to its `OffloadPolicy`.
|
||||
:returns: The same mapping with string keys and each policy serialized to a dictionary.
|
||||
"""
|
||||
return {
|
||||
(json.dumps(list(key)) if isinstance(key, tuple) else key): policy.to_dict()
|
||||
for key, policy in strategies.items()
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_offload_strategies(data: dict[str, Any]) -> dict[str | tuple[str, ...], OffloadPolicy]:
|
||||
"""
|
||||
Deserialize an offload-strategies mapping from its serialized form.
|
||||
|
||||
Reverses `_serialize_offload_strategies`: each policy is rebuilt from its stored type via
|
||||
`deserialize_component_inplace`, and keys that were encoded as JSON-array strings become tuples of tool names
|
||||
(single tool-name and `"*"` keys are kept as-is).
|
||||
|
||||
:param data: Raw dictionary of serialized offload strategies, keyed by tool name(s).
|
||||
:returns: The offload strategies with their original key and policy types restored.
|
||||
"""
|
||||
for raw_key in list(data):
|
||||
deserialize_component_inplace(data, key=raw_key)
|
||||
return {
|
||||
(tuple(json.loads(raw_key)) if isinstance(raw_key, str) and raw_key.startswith("[") else raw_key): policy
|
||||
for raw_key, policy in data.items()
|
||||
}
|
||||
|
||||
|
||||
class ToolResultOffloadHook:
|
||||
"""
|
||||
Offload tool results to a `ToolResultStore`, replacing them in the conversation with a compact pointer.
|
||||
|
||||
This `after_tool` Agent hook writes the full result to the store so the next LLM call sees a reference instead of
|
||||
the full result. Register it on an `Agent` under the `after_tool` hook point. Which tools offload, and under what
|
||||
condition, is controlled per tool by `offload_strategies`:
|
||||
|
||||
```python
|
||||
from haystack.components.agents import Agent
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.hooks.tool_result_offloading import (
|
||||
AlwaysOffload,
|
||||
FileSystemToolResultStore,
|
||||
NeverOffload,
|
||||
OffloadOverChars,
|
||||
ToolResultOffloadHook,
|
||||
)
|
||||
|
||||
hook = ToolResultOffloadHook(
|
||||
store=FileSystemToolResultStore(root="tool_results"),
|
||||
offload_strategies={
|
||||
"web_search": AlwaysOffload(), # force offload
|
||||
"get_time": NeverOffload(), # opt out
|
||||
("read_file", "list_dir"): OffloadOverChars(4000), # tuple key: shared policy
|
||||
"*": OffloadOverChars(8000), # wildcard default for any unlisted tool
|
||||
},
|
||||
)
|
||||
agent = Agent(
|
||||
chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
|
||||
tools=[web_search, get_time, read_file, list_dir],
|
||||
hooks={"after_tool": [hook]},
|
||||
)
|
||||
```
|
||||
|
||||
A key may be a single tool name, a tuple of tool names sharing one policy, or the wildcard `"*"` which applies to
|
||||
any tool without a more specific entry. More specific keys win. A tool with no matching key (and no `"*"`) is not
|
||||
offloaded.
|
||||
|
||||
Only successful, text tool output is offloaded. Error results (including `before_tool` human-in-the-loop
|
||||
rejections) are always left in context. Non-text results (image or file content) are also left in context, and a
|
||||
warning is logged when such a result has a matching offload policy; supporting only text is a deliberate choice
|
||||
for now. Each result is offloaded at most once, even though the hook runs on every tool step.
|
||||
|
||||
The hook keeps no mutable state, so a single instance can be shared across concurrent runs. The constructor
|
||||
`store`, however, is shared by every run that does not override it — fine for single-user or local use, but in a
|
||||
multi-user server give each run its own isolated store (a per-session directory or sandbox) via `hook_context`
|
||||
under the key `RESULT_STORE_CONTEXT_KEY`
|
||||
(`agent.run(messages=[...], hook_context={RESULT_STORE_CONTEXT_KEY: per_request_store})`); it overrides the
|
||||
constructor store for that run. Isolating the store per run keeps concurrent users from colliding on store keys or
|
||||
reading each other's offloaded results — important especially when a bash/read tool is scoped to the store.
|
||||
"""
|
||||
|
||||
allowed_hook_points = ("after_tool",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: ToolResultStore,
|
||||
offload_strategies: dict[str | tuple[str, ...], OffloadPolicy],
|
||||
*,
|
||||
preview_chars: int = 200,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the hook with a store and per-tool offload strategies.
|
||||
|
||||
:param store: Where offloaded results are written. Can be overridden per run via `hook_context`.
|
||||
:param offload_strategies: Mapping of tool name (or a tuple of tool names, or the wildcard `"*"`) to the
|
||||
`OffloadPolicy` that decides whether that tool's results are offloaded.
|
||||
:param preview_chars: Number of leading characters of the original result to include in the pointer left in
|
||||
the conversation, so the model knows roughly what was offloaded.
|
||||
"""
|
||||
self.store = store
|
||||
self.offload_strategies = offload_strategies
|
||||
self.preview_chars = preview_chars
|
||||
|
||||
def run(self, state: State) -> None:
|
||||
"""
|
||||
Offload the freshly produced tool results in `state.data["messages"]` according to `offload_strategies`.
|
||||
|
||||
Considers only the trailing block of tool-result messages (the current step's results); earlier history is
|
||||
left untouched. Offloads each of those messages its policy opts in for, and writes the rewritten conversation
|
||||
back to `messages` only if at least one message changed.
|
||||
|
||||
Results are written to the store this run resolves to: a per-run store passed in `state`'s `hook_context`
|
||||
under `RESULT_STORE_CONTEXT_KEY` if present, otherwise the store the hook was constructed with. Supply the
|
||||
per-run store when calling the Agent, e.g.
|
||||
`agent.run(messages=[...], hook_context={RESULT_STORE_CONTEXT_KEY: per_request_store})`. In a multi-user
|
||||
server, pass an isolated store per run this way so concurrent users write to separate locations and never
|
||||
read each other's results.
|
||||
|
||||
The hook keeps no mutable state, so a single instance is safe to share across concurrent runs; isolation
|
||||
comes entirely from giving each run its own store via `hook_context`.
|
||||
|
||||
:param state: The Agent's live `State`. Reads the per-run store from `hook_context` and rewrites the offloaded
|
||||
tool-result messages back into `messages`.
|
||||
:returns: None. The hook mutates `state` in place.
|
||||
"""
|
||||
messages = state.data.get("messages") or []
|
||||
start = _fresh_tool_results_start(messages)
|
||||
if start == len(messages):
|
||||
return
|
||||
store = self._resolve_store(state)
|
||||
rewritten: list[ChatMessage] = list(messages[:start])
|
||||
changed = False
|
||||
for index, message in enumerate(messages[start:]):
|
||||
new_message = self._maybe_offload(message, store, state, index)
|
||||
rewritten.append(new_message)
|
||||
changed = changed or new_message is not message
|
||||
if changed:
|
||||
state.set("messages", rewritten, handler_override=replace_values)
|
||||
|
||||
def _resolve_store(self, state: State) -> ToolResultStore:
|
||||
"""
|
||||
Return the store to write to for this run.
|
||||
|
||||
:param state: The Agent's live `State`, whose `hook_context` may carry a per-run store override under
|
||||
`RESULT_STORE_CONTEXT_KEY`.
|
||||
:returns: The per-run store from `hook_context` if provided, otherwise the store the hook was built with.
|
||||
"""
|
||||
context = state.data.get("hook_context") or {}
|
||||
return context.get(RESULT_STORE_CONTEXT_KEY, self.store)
|
||||
|
||||
def _policy_for(self, tool_name: str) -> OffloadPolicy | None:
|
||||
"""
|
||||
Resolve the offload policy that applies to a tool, most specific first.
|
||||
|
||||
Lookup order: an exact tool-name key, then any tuple key that contains the tool name, then the `"*"` wildcard.
|
||||
|
||||
:param tool_name: The name of the tool whose policy to resolve.
|
||||
:returns: The matching `OffloadPolicy`, or None when no key (and no `"*"`) applies.
|
||||
"""
|
||||
strategies = self.offload_strategies
|
||||
if tool_name in strategies:
|
||||
return strategies[tool_name]
|
||||
for key, policy in strategies.items():
|
||||
if isinstance(key, tuple) and tool_name in key:
|
||||
return policy
|
||||
return strategies.get("*")
|
||||
|
||||
def _maybe_offload(self, message: ChatMessage, store: ToolResultStore, state: State, index: int) -> ChatMessage:
|
||||
"""
|
||||
Offload a single tool-result message if its policy opts in, otherwise return it unchanged.
|
||||
|
||||
A message is left as-is when it is not a tool result, when the result is an error (including `before_tool`
|
||||
human-in-the-loop rejections), when it was already offloaded (e.g. another offload hook under `after_tool`
|
||||
handled it), when no policy applies, when the result is non-text (contains image or file content), or when the
|
||||
policy declines to offload.
|
||||
|
||||
Otherwise the result text is written to `store` and the message is rebuilt with a pointer in place of the full
|
||||
result, preserving its origin and error flag and marking it offloaded.
|
||||
|
||||
:param message: The message to consider offloading.
|
||||
:param store: The store to write the result to.
|
||||
:param state: The Agent's live `State`, passed to the policy and used to derive the store key.
|
||||
:param index: The message's position within this step's batch of tool results, used to build the store key.
|
||||
:returns: An offloaded copy of the message, or the original message when it is not offloaded.
|
||||
"""
|
||||
result = message.tool_call_result
|
||||
# Only successful tool output is offloaded - never errors, before_tool human-in-the-loop rejections, or a
|
||||
# result already offloaded (guards against a second offload hook re-offloading the first one's pointer).
|
||||
if result is None or result.error or message.meta.get(_OFFLOADED_META_KEY):
|
||||
return message
|
||||
|
||||
tool_name = result.origin.tool_name
|
||||
policy = self._policy_for(tool_name)
|
||||
|
||||
# If no policy applies, leave the result in context
|
||||
if policy is None:
|
||||
return message
|
||||
|
||||
# A policy matched, so an offload was wanted. Offloading only supports text results (a string or a sequence
|
||||
# of TextContent) for now, by design; leave image/file content in context and warn since the intent was to
|
||||
# offload it.
|
||||
text = _offloadable_text(result.result)
|
||||
if text is None:
|
||||
logger.warning(
|
||||
"Tool '{tool}' produced a non-text result; leaving it in context. Result offloading currently "
|
||||
"supports text results only.",
|
||||
tool=tool_name,
|
||||
)
|
||||
return message
|
||||
|
||||
# If the policy declines to offload, leave the result in context
|
||||
if not policy.should_offload(tool_name, text, state):
|
||||
return message
|
||||
|
||||
key = _result_store_key(tool_name, result.origin.id, state.data.get("step_count", 0), index)
|
||||
reference = store.write(key=key, content=text)
|
||||
return ChatMessage.from_tool(
|
||||
tool_result=self._pointer(reference, text),
|
||||
origin=result.origin,
|
||||
error=result.error,
|
||||
meta={**message.meta, _OFFLOADED_META_KEY: reference},
|
||||
)
|
||||
|
||||
def _pointer(self, reference: str, result: str) -> str:
|
||||
"""
|
||||
Build the compact pointer that replaces a full result in the conversation.
|
||||
|
||||
:param reference: The store reference the result was written to.
|
||||
:param result: The original result string, used for its length and a leading preview.
|
||||
:returns: A one-line pointer carrying the reference, the result length, and a `preview_chars`-long preview.
|
||||
"""
|
||||
ellip = "..." if len(result) > self.preview_chars else ""
|
||||
preview = result[: self.preview_chars]
|
||||
return f"Tool result offloaded to '{reference}' ({len(result)} characters). Preview: {preview}{ellip}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize the hook, including its store and per-tool offload strategies.
|
||||
|
||||
:returns: A dictionary representation of the hook.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
store=self.store.to_dict(),
|
||||
offload_strategies=_serialize_offload_strategies(self.offload_strategies),
|
||||
preview_chars=self.preview_chars,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ToolResultOffloadHook":
|
||||
"""
|
||||
Deserialize the hook, reconstructing its store and offload strategies.
|
||||
|
||||
:param data: A dictionary representation produced by `to_dict`.
|
||||
:returns: The deserialized `ToolResultOffloadHook`.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
if init_params.get("store") is not None:
|
||||
deserialize_component_inplace(init_params, key="store")
|
||||
if init_params.get("offload_strategies") is not None:
|
||||
init_params["offload_strategies"] = _deserialize_offload_strategies(init_params["offload_strategies"])
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,70 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack.components.agents.state.state import State
|
||||
from haystack.core.serialization import default_to_dict
|
||||
from haystack.hooks.tool_result_offloading.types import OffloadPolicy
|
||||
|
||||
|
||||
class AlwaysOffload(OffloadPolicy):
|
||||
"""Offload every result of the tool it is assigned to."""
|
||||
|
||||
def should_offload(self, tool_name: str, result: str, state: State) -> bool: # noqa: ARG002
|
||||
"""
|
||||
Decide whether to offload the given tool result.
|
||||
|
||||
:param tool_name: The name of the tool that produced the result (unused; this policy always offloads).
|
||||
:param result: The tool result string (unused; this policy always offloads).
|
||||
:param state: The Agent's live `State` (unused; this policy always offloads).
|
||||
:returns: Always True.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
class NeverOffload(OffloadPolicy):
|
||||
"""Never offload; keep the tool's full result in context. Use to opt a tool out of a wildcard default."""
|
||||
|
||||
def should_offload(self, tool_name: str, result: str, state: State) -> bool: # noqa: ARG002
|
||||
"""
|
||||
Decide whether to offload the given tool result.
|
||||
|
||||
:param tool_name: The name of the tool that produced the result (unused; this policy never offloads).
|
||||
:param result: The tool result string (unused; this policy never offloads).
|
||||
:param state: The Agent's live `State` (unused; this policy never offloads).
|
||||
:returns: Always False.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
class OffloadOverChars(OffloadPolicy):
|
||||
"""Offload a result only when its string length exceeds `threshold` characters."""
|
||||
|
||||
def __init__(self, threshold: int) -> None:
|
||||
"""
|
||||
Initialize the policy with its character threshold.
|
||||
|
||||
:param threshold: Offload the result when its length in characters is strictly greater than this value.
|
||||
"""
|
||||
self.threshold = threshold
|
||||
|
||||
def should_offload(self, tool_name: str, result: str, state: State) -> bool: # noqa: ARG002
|
||||
"""
|
||||
Decide whether to offload the given tool result based on its length.
|
||||
|
||||
:param tool_name: The name of the tool that produced the result (unused; only length is considered).
|
||||
:param result: The tool result string whose length is compared against the threshold.
|
||||
:param state: The Agent's live `State` (unused; only length is considered).
|
||||
:returns: True when `result` is longer than `threshold` characters, otherwise False.
|
||||
"""
|
||||
return len(result) > self.threshold
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize the policy, including its threshold.
|
||||
|
||||
:returns: A dictionary representation of the policy.
|
||||
"""
|
||||
return default_to_dict(self, threshold=self.threshold)
|
||||
@@ -0,0 +1,78 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack.core.serialization import default_from_dict, default_to_dict
|
||||
from haystack.hooks.tool_result_offloading.types import ToolResultStore
|
||||
|
||||
|
||||
class FileSystemToolResultStore(ToolResultStore):
|
||||
"""
|
||||
A `ToolResultStore` that writes offloaded tool results to files under a root directory on the local file system.
|
||||
|
||||
```python
|
||||
from haystack.hooks.tool_result_offloading import FileSystemToolResultStore
|
||||
|
||||
store = FileSystemToolResultStore(root="tool_results")
|
||||
reference = store.write(key="search_1.txt", content="...")
|
||||
store.read(reference)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
"""
|
||||
Initialize the store with the root directory results are written under.
|
||||
|
||||
:param root: Directory under which result files are written. Created on first write if it does not exist.
|
||||
"""
|
||||
self.root = Path(root)
|
||||
|
||||
def write(self, *, key: str, content: str) -> str:
|
||||
"""
|
||||
Write `content` to `<root>/<key>`, creating parent directories, and return the file path.
|
||||
|
||||
The resolved target must stay within the root directory: a `key` that escapes it (e.g. containing `../` or an
|
||||
absolute path) is rejected, so a tool-provided key cannot write outside the store.
|
||||
|
||||
:param key: Relative file name for the result within the store root.
|
||||
:param content: The tool result to persist.
|
||||
:returns: The absolute path the content was written to, as a string, for use with `read`.
|
||||
:raises ValueError: If `key` resolves to a location outside the store root.
|
||||
"""
|
||||
root = self.root.resolve()
|
||||
path = (root / key).resolve()
|
||||
if not path.is_relative_to(root):
|
||||
raise ValueError(f"Result key '{key}' resolves outside the store root '{root}'.")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
def read(self, reference: str) -> str:
|
||||
"""
|
||||
Read back the content previously written to `reference`.
|
||||
|
||||
:param reference: A path returned by `write`.
|
||||
:returns: The stored content.
|
||||
"""
|
||||
return Path(reference).read_text(encoding="utf-8")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize the store, storing its root directory as a string.
|
||||
|
||||
:returns: A dictionary representation of the store.
|
||||
"""
|
||||
return default_to_dict(self, root=str(self.root))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "FileSystemToolResultStore":
|
||||
"""
|
||||
Deserialize the store from a dictionary.
|
||||
|
||||
:param data: A dictionary representation produced by `to_dict`.
|
||||
:returns: The deserialized `FileSystemToolResultStore`.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .protocol import OffloadPolicy, ToolResultStore
|
||||
|
||||
__all__ = ["OffloadPolicy", "ToolResultStore"]
|
||||
@@ -0,0 +1,76 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from haystack.components.agents.state.state import State
|
||||
from haystack.core.serialization import default_from_dict, default_to_dict
|
||||
|
||||
|
||||
class ToolResultStore(Protocol):
|
||||
"""
|
||||
A place a `ToolResultOffloadHook` writes offloaded tool results to, and reads them back from.
|
||||
|
||||
Implementations decide where and how the content lives (local disk, an isolated sandbox filesystem, object
|
||||
storage, ...). `write` returns an opaque reference string that the Agent puts in the conversation in place of the
|
||||
full result; `read` resolves that reference back to the original content.
|
||||
|
||||
Implement both `to_dict` and `from_dict` to make a custom store serializable; the default implementations below
|
||||
cover stores whose constructor takes no arguments.
|
||||
"""
|
||||
|
||||
def write(self, *, key: str, content: str) -> str:
|
||||
"""
|
||||
Persist `content` under `key` and return an opaque reference to it.
|
||||
|
||||
:param key: A stable, per-result identifier the hook derives from the tool call (e.g. a file name).
|
||||
:param content: The tool result to persist.
|
||||
:returns: A reference string (e.g. a path or URI) that `read` can later resolve.
|
||||
"""
|
||||
...
|
||||
|
||||
def read(self, reference: str) -> str:
|
||||
"""Return the content previously stored under `reference`."""
|
||||
...
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the store to a dictionary."""
|
||||
return default_to_dict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ToolResultStore":
|
||||
"""Deserialize the store from a dictionary."""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
|
||||
class OffloadPolicy(Protocol):
|
||||
"""
|
||||
Decides, per tool result, whether the `ToolResultOffloadHook` offloads it to the store or leaves it in context.
|
||||
|
||||
A `ToolResultOffloadHook` maps tool names to policies, so different tools can offload under different conditions
|
||||
(always, never, or a custom rule such as a size threshold).
|
||||
|
||||
Implement both `to_dict` and `from_dict` to make a custom policy serializable; the default implementations below
|
||||
cover policies whose constructor takes no arguments.
|
||||
"""
|
||||
|
||||
def should_offload(self, tool_name: str, result: str, state: State) -> bool:
|
||||
"""
|
||||
Return whether the given tool result should be offloaded.
|
||||
|
||||
:param tool_name: The name of the tool that produced the result.
|
||||
:param result: The tool result as a string (the content that would otherwise stay in the conversation).
|
||||
:param state: The Agent's live `State`, for policies that decide based on run context.
|
||||
:returns: True to offload the result to the store, False to leave it in context.
|
||||
"""
|
||||
...
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the policy to a dictionary."""
|
||||
return default_to_dict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OffloadPolicy":
|
||||
"""Deserialize the policy from a dictionary."""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,114 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.hooks.protocol import Hook, HookPoint
|
||||
from haystack.utils.deserialization import deserialize_component_inplace
|
||||
|
||||
|
||||
# Hooks are (de)serialized with `component_to_dict` / `deserialize_component_inplace` even though they aren't
|
||||
# Components. Despite the name, those helpers aren't component-specific: they just produce/consume the standard
|
||||
# `{"type", "init_parameters"}` dict, and deserialization enforces the import allowlist (so only trusted modules are
|
||||
# loaded).
|
||||
def _serialize_hooks_dictionary(hooks: dict[HookPoint, list[Hook]]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
Serialize a hook-point-keyed dict of hooks to plain dictionaries.
|
||||
|
||||
:param hooks: Hooks keyed by hook point; each hook must implement `to_dict`.
|
||||
:returns: The same mapping with each hook replaced by its serialized dictionary.
|
||||
"""
|
||||
return {
|
||||
hook_point: [component_to_dict(obj=h, name="hook") for h in hook_list]
|
||||
for hook_point, hook_list in hooks.items()
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_hooks_dictionary(data: dict[str, list[dict[str, Any]]]) -> dict[str, list[Hook]]:
|
||||
"""
|
||||
Deserialize a hook-point-keyed dict of hooks from its serialized form.
|
||||
|
||||
:param data: Hook-point-keyed lists of serialized hook dictionaries (each with a `type` field).
|
||||
:returns: The same mapping with each entry rebuilt into a `Hook` instance.
|
||||
"""
|
||||
deserialized: dict[str, list[Hook]] = {}
|
||||
for hook_point, serialized_hooks in data.items():
|
||||
hooks: list[Hook] = []
|
||||
for serialized_hook in serialized_hooks:
|
||||
wrapper: dict[str, Any] = {"hook": serialized_hook}
|
||||
deserialize_component_inplace(wrapper, key="hook")
|
||||
hooks.append(wrapper["hook"])
|
||||
deserialized[hook_point] = hooks
|
||||
return deserialized
|
||||
|
||||
|
||||
def _unique_hooks(hooks: dict[HookPoint, list[Hook]]) -> list[Hook]:
|
||||
"""
|
||||
Collect each distinct hook once, preserving first-seen order.
|
||||
|
||||
A hook may be registered under several hook points; deduplicating by identity ensures lifecycle methods
|
||||
(warm up / close) run once per hook object.
|
||||
|
||||
:param hooks: Hooks keyed by hook point.
|
||||
:returns: The distinct hook objects, in the order first encountered.
|
||||
"""
|
||||
unique: list[Hook] = []
|
||||
seen: set[int] = set()
|
||||
for hook_list in hooks.values():
|
||||
for h in hook_list:
|
||||
if id(h) not in seen:
|
||||
seen.add(id(h))
|
||||
unique.append(h)
|
||||
return unique
|
||||
|
||||
|
||||
def warm_up_hooks(hooks: dict[HookPoint, list[Hook]]) -> None:
|
||||
"""
|
||||
Warm up every hook that defines a `warm_up` method (e.g. to open clients or load credentials).
|
||||
|
||||
:param hooks: Hooks keyed by hook point. Each distinct hook is warmed up at most once.
|
||||
"""
|
||||
for h in _unique_hooks(hooks):
|
||||
if hasattr(h, "warm_up"):
|
||||
h.warm_up()
|
||||
|
||||
|
||||
async def warm_up_hooks_async(hooks: dict[HookPoint, list[Hook]]) -> None:
|
||||
"""
|
||||
Warm up every hook, awaiting `warm_up_async` when defined and falling back to `warm_up` otherwise.
|
||||
|
||||
:param hooks: Hooks keyed by hook point. Each distinct hook is warmed up at most once.
|
||||
"""
|
||||
for h in _unique_hooks(hooks):
|
||||
warm_up_async = getattr(h, "warm_up_async", None)
|
||||
if warm_up_async is not None:
|
||||
await warm_up_async()
|
||||
elif hasattr(h, "warm_up"):
|
||||
h.warm_up()
|
||||
|
||||
|
||||
def close_hooks(hooks: dict[HookPoint, list[Hook]]) -> None:
|
||||
"""
|
||||
Release the resources of every hook that defines a `close` method.
|
||||
|
||||
:param hooks: Hooks keyed by hook point. Each distinct hook is closed at most once.
|
||||
"""
|
||||
for h in _unique_hooks(hooks):
|
||||
if hasattr(h, "close"):
|
||||
h.close()
|
||||
|
||||
|
||||
async def close_hooks_async(hooks: dict[HookPoint, list[Hook]]) -> None:
|
||||
"""
|
||||
Release hook resources, awaiting `close_async` when defined and falling back to `close` otherwise.
|
||||
|
||||
:param hooks: Hooks keyed by hook point. Each distinct hook is closed at most once.
|
||||
"""
|
||||
for h in _unique_hooks(hooks):
|
||||
close_async = getattr(h, "close_async", None)
|
||||
if close_async is not None:
|
||||
await close_async()
|
||||
elif hasattr(h, "close"):
|
||||
h.close()
|
||||
Reference in New Issue
Block a user