diff --git a/haystack/components/agents/agent.py b/haystack/components/agents/agent.py index dbc2f2f9344..b410bb6ef71 100644 --- a/haystack/components/agents/agent.py +++ b/haystack/components/agents/agent.py @@ -53,6 +53,7 @@ flatten_tools_or_toolsets, serialize_tools_or_toolset, ) +from haystack.tools.tool_cache import ToolCache from haystack.utils import _deserialize_value_with_schema from haystack.utils.callable_serialization import deserialize_callable, serialize_callable from haystack.utils.deserialization import deserialize_component_inplace @@ -206,6 +207,21 @@ def translate( print(result["last_message"].text) ``` + #### Using a `tool_cache` to avoid redundant tool calls + + When an Agent loops over several steps, it can end up calling the same read-only tool with the same + arguments more than once (for example, re-fetching a document it already fetched a few steps earlier). + Pass a `ToolCache` to avoid re-invoking tools that have opted in via `Tool(..., cacheable=True)`: + + ```python + from haystack.components.agents import Agent + from haystack.tools.tool_cache import ToolCache + + cache = ToolCache(ttl_seconds=3600, scope="agent_run") + agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[my_cacheable_tool], tool_cache=cache) + result = agent.run(messages=[ChatMessage.from_user("...")]) + # result["tool_cache_stats"] -> {"hits": ..., "misses": ..., "calls_saved": ...} + ``` """ def __init__( @@ -223,6 +239,7 @@ def __init__( raise_on_tool_invocation_failure: bool = False, tool_invoker_kwargs: dict[str, Any] | None = None, confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy] | None = None, + tool_cache: ToolCache | None = None, ) -> None: """ Initialize the agent component. @@ -254,6 +271,12 @@ def __init__( If set to False, the exception will be turned into a chat message and passed to the LLM. :param tool_invoker_kwargs: Additional keyword arguments to pass to the ToolInvoker. :param confirmation_strategies: A dictionary mapping tool names to ConfirmationStrategy instances. + :param tool_cache: Optional `ToolCache` instance shared across the Agent's run. Passed straight through + to the internal `ToolInvoker`, so tool calls for any `Tool` with `cacheable=True` are looked up + in the cache before invocation and stored after a successful invocation. When set, the dict returned + by `run()`/`run_async()` includes a `"tool_cache_stats"` key with the cache's hit/miss counts for + the run. Not included in `to_dict()`/`from_dict()` serialization, since cache backends are runtime + objects rather than serializable configuration — see `ToolInvoker.tool_cache` for the same caveat. :raises TypeError: If the chat_generator does not support tools parameter in its run method. :raises ValueError: If the exit_conditions are not valid. :raises ValueError: If any `user_prompt` variable overlaps with the `state_schema` or `run` method parameters. @@ -298,10 +321,13 @@ def __init__( self.max_agent_steps = max_agent_steps self.raise_on_tool_invocation_failure = raise_on_tool_invocation_failure self.streaming_callback = streaming_callback + self.tool_cache = tool_cache # Set input and output types for the component based on the State schema self._run_method_params = _get_run_method_params(self) - output_types = {"last_message": ChatMessage} + output_types: dict[str, Any] = {"last_message": ChatMessage} + if self.tool_cache is not None: + output_types["tool_cache_stats"] = dict[str, int] for param, config in self.state_schema.items(): output_types[param] = config["type"] # Skip setting input types for parameters that are already in the run method @@ -327,6 +353,7 @@ def __init__( resolved_tool_invoker_kwargs = { "tools": self.tools, "raise_on_failure": self.raise_on_tool_invocation_failure, + "tool_cache": self.tool_cache, **(tool_invoker_kwargs or {}), } self._tool_invoker = ToolInvoker(**resolved_tool_invoker_kwargs) @@ -407,6 +434,11 @@ def to_dict(self) -> dict[str, Any]: """ Serialize the component to a dictionary. + Note: `tool_cache`, if set, is intentionally NOT included — cache backends are runtime objects + rather than serializable configuration. A deserialized `Agent` will have `tool_cache=None` even + if the original instance had one set; callers that need caching after deserialization should + re-attach a `ToolCache` explicitly. + :returns: Dictionary with serialized data. """ return default_to_dict( @@ -768,6 +800,8 @@ def run( # noqa: PLR0915 A dictionary with the following keys: - "messages": List of all messages exchanged during the agent's run. - "last_message": The last message exchanged during the agent's run. + - "tool_cache_stats": Only present if a `tool_cache` was configured; a dict with `hits`, `misses`, + and `calls_saved` counts for this run. - Any additional keys defined in the `state_schema`. :raises BreakpointException: If an agent breakpoint is triggered. """ @@ -961,6 +995,8 @@ def run( # noqa: PLR0915 result = {**exe_context.state.data} if msgs := result.get("messages"): result["last_message"] = msgs[-1] + if self.tool_cache is not None: + result["tool_cache_stats"] = self.tool_cache.stats.to_dict() return result async def run_async( # noqa: PLR0915 @@ -1011,6 +1047,8 @@ async def run_async( # noqa: PLR0915 A dictionary with the following keys: - "messages": List of all messages exchanged during the agent's run. - "last_message": The last message exchanged during the agent's run. + - "tool_cache_stats": Only present if a `tool_cache` was configured; a dict with `hits`, `misses`, + and `calls_saved` counts for this run. - Any additional keys defined in the `state_schema`. :raises BreakpointException: If an agent breakpoint is triggered. """ @@ -1204,6 +1242,8 @@ async def run_async( # noqa: PLR0915 result = {**exe_context.state.data} if msgs := result.get("messages"): result["last_message"] = msgs[-1] + if self.tool_cache is not None: + result["tool_cache_stats"] = self.tool_cache.stats.to_dict() return result def _check_exit_conditions(self, llm_messages: list[ChatMessage], tool_messages: list[ChatMessage]) -> bool: diff --git a/haystack/components/tools/tool_invoker.py b/haystack/components/tools/tool_invoker.py index 522f9059ca1..133ca29df4b 100644 --- a/haystack/components/tools/tool_invoker.py +++ b/haystack/components/tools/tool_invoker.py @@ -29,6 +29,7 @@ ) from haystack.tools.errors import ToolInvocationError from haystack.tools.parameters_schema_utils import _unwrap_optional +from haystack.tools.tool_cache import ToolCache from haystack.tracing.utils import _serializable_value from haystack.utils.callable_serialization import deserialize_callable, serialize_callable @@ -179,6 +180,18 @@ def dummy_weather_function(city: str): result = invoker.run(messages=[message]) print(result) + ``` + + Usage example with a ToolCache (opt-in per-tool caching, see `Tool.cacheable`): + ```python + from haystack.tools.tool_cache import ToolCache + + cache = ToolCache(ttl_seconds=3600, scope="agent_run") + cacheable_tool = Tool(..., cacheable=True) + invoker = ToolInvoker(tools=[cacheable_tool], tool_cache=cache) + # Identical calls to `cacheable_tool` within the cache's TTL are served from cache; + # `cache.stats` tracks hits/misses for inspection after the run. + ``` """ def __init__( @@ -190,6 +203,7 @@ def __init__( *, enable_streaming_callback_passthrough: bool = False, max_workers: int = 4, + tool_cache: ToolCache | None = None, ) -> None: """ Initialize the ToolInvoker component. @@ -216,6 +230,12 @@ def __init__( :param max_workers: The maximum number of workers to use in the thread pool executor. This also decides the maximum number of concurrent tool invocations. + :param tool_cache: + Optional `ToolCache` instance. When provided, tool calls for any `Tool` with `cacheable=True` + are looked up in the cache before invocation and the result is stored in the cache after a + successful invocation. Tools with `cacheable=False` (the default) always invoke normally, + regardless of whether a `tool_cache` is configured. Not included in `to_dict()`/`from_dict()` + serialization, since cache backends are runtime objects rather than serializable configuration. :raises ValueError: If no tools are provided or if duplicate tool names are found. """ @@ -225,6 +245,7 @@ def __init__( self.max_workers = max_workers self.raise_on_failure = raise_on_failure self.convert_result_to_json_string = convert_result_to_json_string + self.tool_cache = tool_cache self._tools_with_names = self._validate_and_prepare_tools(tools) self._is_warmed_up = False @@ -252,6 +273,21 @@ def _runner() -> Any: return _runner + @staticmethod + def _make_cached_result_invoke(cached_result: Any) -> Callable[[], Any]: + """ + Create a zero-arg callable that simply returns an already-cached result. + + Used in place of `_make_context_bound_invoke` on a cache hit, so the surrounding + ThreadPoolExecutor/future-based control flow in `run`/`run_async` stays uniform + regardless of whether a given call was actually invoked or served from cache. + """ + + def _runner() -> Any: + return cached_result + + return _runner + @staticmethod def _validate_and_prepare_tools(tools: ToolsType) -> dict[str, Tool]: """ @@ -494,6 +530,34 @@ def _create_tool_result_streaming_chunk(tool_messages: list[ChatMessage], tool_c meta={"tool_result": tool_messages[-1].tool_call_results[0].result, "tool_call": tool_call}, ) + def _check_cache(self, tool_to_invoke: Tool, tool_call: ToolCall) -> tuple[bool, Any]: + """ + Look up a tool call in the configured ToolCache, if any. + + Only consulted when `self.tool_cache` is set AND the tool itself opted in via `Tool.cacheable=True`. + A tool with `cacheable=False` (the default) is never looked up, regardless of whether a cache is configured. + + :param tool_to_invoke: The Tool that would be invoked. + :param tool_call: The ToolCall containing the arguments to look up. + :returns: A tuple `(hit, cached_result)`. `hit` is always False if no cache is configured or the tool + is not cacheable. + """ + if self.tool_cache is None or not getattr(tool_to_invoke, "cacheable", False): + return False, None + return self.tool_cache.get(tool_to_invoke.name, tool_call.arguments) + + def _store_in_cache(self, tool_to_invoke: Tool, tool_call: ToolCall, result: Any) -> None: + """ + Store a successful tool invocation result in the configured ToolCache, if any. + + :param tool_to_invoke: The Tool that was invoked. + :param tool_call: The ToolCall containing the arguments used. + :param result: The result to store. + """ + if self.tool_cache is None or not getattr(tool_to_invoke, "cacheable", False): + return + self.tool_cache.set(tool_to_invoke.name, tool_call.arguments, result) + def _prepare_tool_call_params( self, *, @@ -506,6 +570,10 @@ def _prepare_tool_call_params( """ Prepare tool call parameters for execution and collect any error messages. + Also performs the cache lookup for cacheable tools: each entry in the returned `tool_call_params` + list carries `cache_hit` (bool) and `cached_result` (Any), so the caller (`run`/`run_async`) can + skip actual invocation on a hit while keeping a uniform future-based execution path. + :param messages_with_tool_calls: Messages containing tool calls to process :param state: The current state for argument injection :param streaming_callback: Optional streaming callback to inject @@ -544,7 +612,19 @@ def _prepare_tool_call_params( ): final_args["streaming_callback"] = streaming_callback - tool_call_params.append({"tool_to_invoke": tool_to_invoke, "final_args": final_args}) + # Cache lookup happens against the original LLM-provided arguments (tool_call.arguments), + # not final_args, so that state-injected/streaming_callback-injected values (which can vary + # run-to-run without changing the logical call) don't accidentally fragment the cache key. + cache_hit, cached_result = self._check_cache(tool_to_invoke, tool_call) + + tool_call_params.append( + { + "tool_to_invoke": tool_to_invoke, + "final_args": final_args, + "cache_hit": cache_hit, + "cached_result": cached_result, + } + ) tool_calls.append(tool_call) return tool_calls, tool_call_params, error_messages @@ -645,15 +725,19 @@ def run( if not tool_call_params: return {"tool_messages": tool_messages, "state": state} - # 2) Execute valid tool calls in parallel + # 2) Execute valid tool calls in parallel (cache hits get a trivial pass-through callable, + # so the executor/future machinery stays identical regardless of cache outcome) with ThreadPoolExecutor(max_workers=self.max_workers) as executor: futures = [] for params in tool_call_params: - callable_ = self._make_context_bound_invoke(params["tool_to_invoke"], params["final_args"]) + if params["cache_hit"]: + callable_ = self._make_cached_result_invoke(params["cached_result"]) + else: + callable_ = self._make_context_bound_invoke(params["tool_to_invoke"], params["final_args"]) futures.append(executor.submit(callable_)) - # 3) Gather and process results: handle errors and merge outputs into state - for future, tool_call in zip(futures, tool_calls, strict=True): + # 3) Gather and process results: handle errors, store cache misses, and merge outputs into state + for future, tool_call, params in zip(futures, tool_calls, tool_call_params, strict=True): result = future.result() if isinstance(result, ToolInvocationError): @@ -663,9 +747,11 @@ def run( logger.error("{error_exception}", error_exception=result) tool_messages.append(ChatMessage.from_tool(tool_result=str(result), origin=tool_call, error=True)) else: - # b) In case of success, merge outputs into state + # b) In case of success, store cache misses, merge outputs into state try: tool_to_invoke = tools_with_names[tool_call.tool_name] + if not params["cache_hit"]: + self._store_in_cache(tool_to_invoke, tool_call, result) self._merge_tool_outputs(tool=tool_to_invoke, result=result, state=state) tool_messages.append( self._prepare_tool_result_message( @@ -781,17 +867,21 @@ async def run_async( if not tool_call_params: return {"tool_messages": tool_messages, "state": state} - # 2) Execute valid tool calls in parallel + # 2) Execute valid tool calls in parallel (cache hits get a trivial pass-through callable, + # so the executor/future machinery stays identical regardless of cache outcome) tool_call_tasks = [] with ThreadPoolExecutor(max_workers=self.max_workers) as executor: for params in tool_call_params: loop = asyncio.get_running_loop() - callable_ = ToolInvoker._make_context_bound_invoke(params["tool_to_invoke"], params["final_args"]) + if params["cache_hit"]: + callable_ = ToolInvoker._make_cached_result_invoke(params["cached_result"]) + else: + callable_ = ToolInvoker._make_context_bound_invoke(params["tool_to_invoke"], params["final_args"]) tool_call_tasks.append(loop.run_in_executor(executor, callable_)) - # 3) Gather and process results: handle errors and merge outputs into state + # 3) Gather and process results: handle errors, store cache misses, and merge outputs into state tool_results = await asyncio.gather(*tool_call_tasks) - for tool_result, tool_call in zip(tool_results, tool_calls, strict=True): + for tool_result, tool_call, params in zip(tool_results, tool_calls, tool_call_params, strict=True): # a) This is an error, create error Tool message if isinstance(tool_result, ToolInvocationError): if self.raise_on_failure: @@ -801,9 +891,11 @@ async def run_async( ChatMessage.from_tool(tool_result=str(tool_result), origin=tool_call, error=True) ) else: - # b) In case of success, merge outputs into state + # b) In case of success, store cache misses, merge outputs into state try: tool_to_invoke = tools_with_names[tool_call.tool_name] + if not params["cache_hit"]: + self._store_in_cache(tool_to_invoke, tool_call, tool_result) self._merge_tool_outputs(tool=tool_to_invoke, result=tool_result, state=state) tool_messages.append( self._prepare_tool_result_message( @@ -839,6 +931,11 @@ def to_dict(self) -> dict[str, Any]: """ Serializes the component to a dictionary. + Note: `tool_cache`, if set, is intentionally NOT included — cache backends are runtime objects + (in-memory state, possibly external connections) rather than serializable configuration. A + deserialized `ToolInvoker` will have `tool_cache=None` even if the original instance had one set; + callers that need caching after deserialization should re-attach a `ToolCache` explicitly. + :returns: Dictionary with serialized data. """ diff --git a/haystack/tools/component_tool.py b/haystack/tools/component_tool.py index d19d16643f8..dfd733b4ed7 100644 --- a/haystack/tools/component_tool.py +++ b/haystack/tools/component_tool.py @@ -108,6 +108,7 @@ def __init__( outputs_to_string: dict[str, str | Callable[[Any], str]] | None = None, inputs_from_state: dict[str, str] | None = None, outputs_to_state: dict[str, dict[str, str | Callable]] | None = None, + cacheable: bool = False, ) -> None: """ Create a Tool instance from a Haystack component. @@ -166,6 +167,9 @@ def __init__( "documents": {"handler": custom_handler} } ``` + :param cacheable: + If True, results of this tool's invocations may be cached by a `ToolCache` passed to + `ToolInvoker`/`Agent`. See `Tool.cacheable` for details. :raises TypeError: If the object passed is not a Haystack Component instance. :raises ValueError: If the component has already been added to a pipeline, or if schema generation fails. """ @@ -232,6 +236,7 @@ def component_invoker(**kwargs: Any) -> dict[str, Any]: inputs_from_state=inputs_from_state, outputs_to_state=outputs_to_state, outputs_to_string=outputs_to_string, + cacheable=cacheable, ) def _get_valid_inputs(self) -> set[str]: @@ -279,6 +284,7 @@ def to_dict(self) -> dict[str, Any]: "outputs_to_string": _serialize_outputs_to_string(self.outputs_to_string) if self.outputs_to_string else None, + "cacheable": self.cacheable, } return {"type": generate_qualified_class_name(type(self)), "data": serialized} @@ -306,6 +312,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ComponentTool": outputs_to_string=inner_data.get("outputs_to_string", None), inputs_from_state=inner_data.get("inputs_from_state", None), outputs_to_state=inner_data.get("outputs_to_state", None), + cacheable=inner_data.get("cacheable", False), ) def _create_tool_parameters_schema(self, component: Component, inputs_from_state: dict[str, Any]) -> dict[str, Any]: diff --git a/haystack/tools/tool.py b/haystack/tools/tool.py index bb02b57c407..61215fb702a 100644 --- a/haystack/tools/tool.py +++ b/haystack/tools/tool.py @@ -86,6 +86,12 @@ class Tool: "documents": {"handler": custom_handler} } ``` + :param cacheable: + Whether results from this tool may be served from a `ToolCache` when one is configured on the + `ToolInvoker`/`Agent` invoking this tool. Defaults to `False` — caching must be explicitly opted into + per tool so that write-effecting tools (sending a message, posting to an API, mutating remote or local + state) never serve a stale cached result for what should be a fresh side-effecting call. Set this to + `True` only for read-only/idempotent tools such as lookups, fetches, or calculations. :raises ValueError: If `function` is async, if `parameters` is not a valid JSON schema, or if the `outputs_to_state`, `outputs_to_string`, or `inputs_from_state` configurations are invalid. :raises TypeError: If any configuration value in `outputs_to_state`, `outputs_to_string`, or @@ -99,6 +105,7 @@ class Tool: outputs_to_string: dict[str, Any] | None = None inputs_from_state: dict[str, str] | None = None outputs_to_state: dict[str, dict[str, Any]] | None = None + cacheable: bool = False def __post_init__(self) -> None: # noqa: C901, PLR0912 # Check that the function is not a coroutine (async function) diff --git a/haystack/tools/tool_cache.py b/haystack/tools/tool_cache.py new file mode 100644 index 00000000000..7f89f296be9 --- /dev/null +++ b/haystack/tools/tool_cache.py @@ -0,0 +1,220 @@ +# SPDX-FileCopyrightText: 2022-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Opt-in keyed cache for tool invocations. + +Used by ToolInvoker and Agent to avoid re-running identical tool calls within an agent loop. +See https://github.com/deepset-ai/haystack/issues/11588 for the motivating problem +statement and design discussion. +""" + +import hashlib +import json +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from threading import Lock +from typing import Any + +from haystack import logging + +logger = logging.getLogger(__name__) + + +def _canonicalize_args(arguments: dict[str, Any]) -> str: + """ + Produce a stable JSON string for a tool-call arguments dict, used as part of the cache key. + + Falls back to `str(arguments)` if the arguments aren't JSON-serializable (e.g. contain custom + objects), so caching degrades gracefully rather than raising. + + :param arguments: The tool call arguments dictionary. + :returns: A canonical string representation suitable for hashing. + """ + try: + return json.dumps(arguments, sort_keys=True, default=str) + except (TypeError, ValueError): + return str(arguments) + + +def make_cache_key(tool_name: str, arguments: dict[str, Any]) -> str: + """ + Build the cache key for a tool invocation: `(tool_name, sha256(canonicalized_args_json))`. + + :param tool_name: Name of the tool being invoked. + :param arguments: The tool call arguments dictionary. + :returns: A string cache key combining the tool name and a hash of its arguments. + """ + canonical_args = _canonicalize_args(arguments) + args_hash = hashlib.sha256(canonical_args.encode("utf-8")).hexdigest() + return f"{tool_name}:{args_hash}" + + +@dataclass +class ToolCacheStats: + """ + Tracks cache effectiveness for a single cache instance (typically scoped to one agent run). + + :param hits: Number of cache hits (tool call skipped, cached result returned). + :param misses: Number of cache misses (tool was actually invoked). + :param calls_saved: Alias for `hits`, included for readability in run output. + """ + + hits: int = 0 + misses: int = 0 + + @property + def calls_saved(self) -> int: + """Number of tool invocations avoided due to cache hits.""" + return self.hits + + def to_dict(self) -> dict[str, int]: + """Serialize stats to a plain dict for inclusion in Agent run output.""" + return {"hits": self.hits, "misses": self.misses, "calls_saved": self.calls_saved} + + +class ToolCacheBackend(ABC): + """ + Abstract storage backend for `ToolCache`. + + Implementations only need to handle get/set/clear; TTL expiry and scoping are handled by + `ToolCache` itself so that every backend gets that behavior for free. + """ + + @abstractmethod + def get(self, key: str) -> tuple[Any, float] | None: + """ + Retrieve a cached value and its stored timestamp. + + :param key: The cache key. + :returns: A tuple of (value, stored_at_unix_timestamp), or None if not present. + """ + + @abstractmethod + def set(self, key: str, value: Any, stored_at: float) -> None: + """ + Store a value under the given key with the given timestamp. + + :param key: The cache key. + :param value: The value to cache (the tool call result). + :param stored_at: Unix timestamp of when the value was stored. + """ + + @abstractmethod + def clear(self) -> None: + """Remove all entries from the backend.""" + + +class InMemoryToolCache(ToolCacheBackend): + """ + Simple in-process, thread-safe dict-backed cache. + + Not shared across processes and is lost on restart — suitable as the default backend for + single-process agent runs and as a reference implementation for custom backends. + """ + + def __init__(self) -> None: + self._store: dict[str, tuple[Any, float]] = {} + self._lock = Lock() + + def get(self, key: str) -> tuple[Any, float] | None: + """See ToolCacheBackend.get().""" + with self._lock: + return self._store.get(key) + + def set(self, key: str, value: Any, stored_at: float) -> None: + """See ToolCacheBackend.set().""" + with self._lock: + self._store[key] = (value, stored_at) + + def clear(self) -> None: + """See ToolCacheBackend.clear().""" + with self._lock: + self._store.clear() + + +class ToolCache: + """ + Opt-in cache wrapping tool invocations. + + Caching is per-Tool opt-in (`Tool.cacheable`); `ToolCache` itself never decides + whether a given tool's results may be cached — that decision lives entirely on + the `Tool` instance so that write-effecting tools cannot accidentally serve a + cached result for a side-effecting call. + + Usage: + ```python + cache = ToolCache(backend=InMemoryToolCache(), ttl_seconds=3600, scope="agent_run") + agent = Agent(chat_generator=generator, tools=[fetch_url], tool_cache=cache) + ``` + + :param backend: Storage backend. Defaults to a fresh `InMemoryToolCache()`. + :param ttl_seconds: How long a cached entry remains valid. Defaults to 300 (5 minutes), + chosen to deduplicate repeated calls within a single agent loop without letting + staleness compound across longer sessions. + :param scope: Logical scope label for this cache instance. Purely informational/for the + caller's own bookkeeping today — `ToolCache` does not enforce cross-scope isolation + itself; that isolation comes from the caller creating a separate `ToolCache` instance + per scope (e.g. one per agent run). Accepted values: "agent_run", "session", "global". + """ + + _VALID_SCOPES = ("agent_run", "session", "global") + + def __init__( + self, backend: ToolCacheBackend | None = None, ttl_seconds: float = 300.0, scope: str = "agent_run" + ) -> None: + if scope not in self._VALID_SCOPES: + msg = f"scope must be one of {self._VALID_SCOPES}, but got {scope!r}" + raise ValueError(msg) + if ttl_seconds <= 0: + msg = f"ttl_seconds must be > 0, but got {ttl_seconds}" + raise ValueError(msg) + + self.backend = backend if backend is not None else InMemoryToolCache() + self.ttl_seconds = ttl_seconds + self.scope = scope + self.stats = ToolCacheStats() + + def get(self, tool_name: str, arguments: dict[str, Any]) -> tuple[bool, Any]: + """ + Look up a cached result for the given tool call. + + :param tool_name: Name of the tool being invoked. + :param arguments: The tool call arguments dictionary. + :returns: A tuple `(hit, value)`. If `hit` is False, `value` is always None and the + caller should invoke the tool normally and call `set()` with the result. + """ + key = make_cache_key(tool_name, arguments) + cached = self.backend.get(key) + + if cached is None: + self.stats.misses += 1 + return False, None + + value, stored_at = cached + if (time.monotonic() - stored_at) > self.ttl_seconds: + self.stats.misses += 1 + logger.debug("Tool cache entry for {key} expired (TTL {ttl}s)", key=key, ttl=self.ttl_seconds) + return False, None + + self.stats.hits += 1 + logger.debug("Tool cache hit for {tool_name}", tool_name=tool_name) + return True, value + + def set(self, tool_name: str, arguments: dict[str, Any], value: Any) -> None: + """ + Store a tool call result in the cache. + + :param tool_name: Name of the tool that was invoked. + :param arguments: The tool call arguments dictionary. + :param value: The result to cache. + """ + key = make_cache_key(tool_name, arguments) + self.backend.set(key, value, time.monotonic()) + + def clear(self) -> None: + """Clear all cached entries and reset stats.""" + self.backend.clear() + self.stats = ToolCacheStats() diff --git a/releasenotes/notes/add-tool-cache-c5aa2f20c6fefc17.yaml b/releasenotes/notes/add-tool-cache-c5aa2f20c6fefc17.yaml new file mode 100644 index 00000000000..cbc964c8b20 --- /dev/null +++ b/releasenotes/notes/add-tool-cache-c5aa2f20c6fefc17.yaml @@ -0,0 +1,7 @@ +features: + - | + Added an opt-in ``ToolCache`` for caching tool invocation results. Tools opt in via the new + ``Tool.cacheable`` field (defaults to ``False``); when a ``ToolCache`` is passed to ``ToolInvoker`` + or ``Agent``, identical calls to a cacheable tool within the cache's TTL are served from cache instead + of re-invoked. ``Agent.run()``/``run_async()`` include a ``tool_cache_stats`` key in their output when + a cache is configured, reporting hits/misses/calls_saved for the run. diff --git a/test/components/agents/test_agent.py b/test/components/agents/test_agent.py index 155f70e82be..3b1bd5081ae 100644 --- a/test/components/agents/test_agent.py +++ b/test/components/agents/test_agent.py @@ -31,6 +31,7 @@ from haystack.dataclasses.streaming_chunk import StreamingChunk from haystack.document_stores.in_memory import InMemoryDocumentStore from haystack.tools import ComponentTool, Tool, tool +from haystack.tools.tool_cache import ToolCache from haystack.tools.toolset import Toolset from haystack.tracing.logging_tracer import LoggingTracer from haystack.utils import Secret, serialize_callable @@ -244,6 +245,7 @@ def test_to_dict(self, weather_tool, component_tool, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, }, { @@ -263,6 +265,7 @@ def test_to_dict(self, weather_tool, component_tool, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, }, ], @@ -326,6 +329,7 @@ def test_to_dict_with_toolset(self, monkeypatch, weather_tool): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ] @@ -395,6 +399,7 @@ def test_from_dict(self, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, }, { @@ -414,6 +419,7 @@ def test_from_dict(self, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, }, ], @@ -482,6 +488,7 @@ def test_from_dict_with_toolset(self, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ] @@ -542,6 +549,7 @@ def test_from_dict_state_schema_none(self, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, }, { @@ -561,6 +569,7 @@ def test_from_dict_state_schema_none(self, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, }, ], @@ -792,6 +801,30 @@ def test_exit_condition_exits(self, monkeypatch, weather_tool): assert isinstance(result["last_message"], ChatMessage) assert result["messages"][-1] == result["last_message"] + def test_run_with_tool_cache_includes_stats_in_output(self, monkeypatch, weather_tool): + monkeypatch.setenv("OPENAI_API_KEY", "fake-key") + generator = OpenAIChatGenerator() + + weather_tool.cacheable = True + cache = ToolCache() + agent = Agent(chat_generator=generator, tools=[weather_tool], tool_cache=cache) + + tool_call_reply = { + "replies": [ + ChatMessage.from_assistant( + tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})] + ) + ] + } + text_exit_reply = {"replies": [ChatMessage.from_assistant("The weather is sunny.")]} + agent.chat_generator.run = MagicMock(side_effect=[tool_call_reply, text_exit_reply]) + + result = agent.run([ChatMessage.from_user("What's the weather in Berlin?")]) + + assert "tool_cache_stats" in result + assert isinstance(result["tool_cache_stats"], dict) + assert set(result["tool_cache_stats"].keys()) == {"hits", "misses", "calls_saved"} + def test_does_not_exit_on_empty_assistant_message(self, monkeypatch, weather_tool): monkeypatch.setenv("OPENAI_API_KEY", "fake-key") agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool], exit_conditions=["text"]) @@ -1181,11 +1214,11 @@ def test_agent_tracing_span_run(self, caplog, monkeypatch, weather_tool): '{"messages": "list", "tools": "list"}', '{"messages": {"type": "list[haystack.dataclasses.chat_message.ChatMessage]", "senders": []}, "tools": {"type": "list[haystack.tools.tool.Tool] | haystack.tools.toolset.Toolset | None", "senders": []}}', # noqa: E501 '{"replies": {"type": "list[haystack.dataclasses.chat_message.ChatMessage]", "receivers": []}}', - '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "tools": [{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null}}]}', # noqa: E501 + '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "tools": [{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null, "cacheable": false}}]}', # noqa: E501 1, '{"replies": [{"role": "assistant", "meta": {}, "name": null, "content": [{"text": "Hello"}]}]}', 100, - '[{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null}}]', # noqa: E501 + '[{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null, "cacheable": false}}]', # noqa: E501 '["text"]', '{"messages": {"type": "list[haystack.dataclasses.chat_message.ChatMessage]", "handler": "haystack.components.agents.state.state_utils.merge_lists"}}', # noqa: E501 '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "streaming_callback": null, "break_point": null, "snapshot": null}', # noqa: E501 @@ -1243,11 +1276,11 @@ async def test_agent_tracing_span_async_run(self, caplog, monkeypatch, weather_t '{"messages": "list", "tools": "list"}', '{"messages": {"type": "list[haystack.dataclasses.chat_message.ChatMessage]", "senders": []}, "tools": {"type": "list[haystack.tools.tool.Tool] | haystack.tools.toolset.Toolset | None", "senders": []}}', # noqa: E501 '{"replies": {"type": "list[haystack.dataclasses.chat_message.ChatMessage]", "receivers": []}}', - '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "tools": [{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null}}]}', # noqa: E501 + '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "tools": [{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null, "cacheable": false}}]}', # noqa: E501 1, '{"replies": [{"role": "assistant", "meta": {}, "name": null, "content": [{"text": "Hello from run_async"}]}]}', # noqa: E501 100, - '[{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null}}]', # noqa: E501 + '[{"type": "haystack.tools.tool.Tool", "data": {"name": "weather_tool", "description": "Provides weather information for a given location.", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "function": "test_agent.weather_function", "outputs_to_string": null, "inputs_from_state": null, "outputs_to_state": null, "cacheable": false}}]', # noqa: E501 '["text"]', '{"messages": {"type": "list[haystack.dataclasses.chat_message.ChatMessage]", "handler": "haystack.components.agents.state.state_utils.merge_lists"}}', # noqa: E501 '{"messages": [{"role": "user", "meta": {}, "name": null, "content": [{"text": "What\'s the weather in Paris?"}]}], "streaming_callback": null, "break_point": null, "snapshot": null}', # noqa: E501 diff --git a/test/components/agents/test_agent_breakpoints.py b/test/components/agents/test_agent_breakpoints.py index a1730b0da2e..585f37200a8 100644 --- a/test/components/agents/test_agent_breakpoints.py +++ b/test/components/agents/test_agent_breakpoints.py @@ -136,6 +136,7 @@ def test_run_with_chat_generator_breakpoint(self, agent, chat_generator_serializ "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ], @@ -198,6 +199,7 @@ def test_run_with_chat_generator_breakpoint(self, agent, chat_generator_serializ "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ], @@ -270,6 +272,7 @@ def test_run_with_tool_invoker_breakpoint(self, agent, chat_generator_serializat "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ], @@ -368,6 +371,7 @@ def test_run_with_tool_invoker_breakpoint(self, agent, chat_generator_serializat "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ], diff --git a/test/components/agents/test_agent_hitl.py b/test/components/agents/test_agent_hitl.py index 78b2023f41d..0b0edd77963 100644 --- a/test/components/agents/test_agent_hitl.py +++ b/test/components/agents/test_agent_hitl.py @@ -93,6 +93,7 @@ def test_to_dict(self, tools, confirmation_strategies, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ], diff --git a/test/components/generators/chat/test_azure.py b/test/components/generators/chat/test_azure.py index 517ba7ea4a6..1b77a13efb7 100644 --- a/test/components/generators/chat/test_azure.py +++ b/test/components/generators/chat/test_azure.py @@ -523,6 +523,7 @@ def test_to_dict_with_toolset(self, tools, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ] diff --git a/test/components/generators/chat/test_azure_responses.py b/test/components/generators/chat/test_azure_responses.py index 96e50169138..eb54b48fadf 100644 --- a/test/components/generators/chat/test_azure_responses.py +++ b/test/components/generators/chat/test_azure_responses.py @@ -249,6 +249,7 @@ def test_to_dict_with_toolset(self, tools, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ] diff --git a/test/components/generators/chat/test_hugging_face_api.py b/test/components/generators/chat/test_hugging_face_api.py index 1bb91f8e3ed..65bfa95c0d1 100644 --- a/test/components/generators/chat/test_hugging_face_api.py +++ b/test/components/generators/chat/test_hugging_face_api.py @@ -278,6 +278,7 @@ def test_to_dict(self, mock_check_valid_model): "inputs_from_state": None, "name": "name", "outputs_to_state": None, + "cacheable": False, "outputs_to_string": None, "parameters": {"x": {"type": "string"}}, }, @@ -342,6 +343,7 @@ def test_serde_in_pipeline(self, mock_check_valid_model): "inputs_from_state": None, "name": "name", "outputs_to_state": None, + "cacheable": False, "outputs_to_string": None, "description": "description", "parameters": {"x": {"type": "string"}}, @@ -1215,6 +1217,7 @@ def test_to_dict_with_toolset(self, mock_check_valid_model, tools): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ] diff --git a/test/components/generators/chat/test_hugging_face_local.py b/test/components/generators/chat/test_hugging_face_local.py index 06069999b5d..33989e03c31 100644 --- a/test/components/generators/chat/test_hugging_face_local.py +++ b/test/components/generators/chat/test_hugging_face_local.py @@ -206,6 +206,7 @@ def test_to_dict(self, model_info_mock, tools): "inputs_from_state": None, "name": "weather", "outputs_to_state": None, + "cacheable": False, "outputs_to_string": None, "description": "useful to determine the weather in a given location", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, @@ -786,6 +787,7 @@ def test_to_dict_with_toolset(self, model_info_mock, mock_pipeline_with_tokenize "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ] diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index 9a34f2ca469..599fd8ee063 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -346,6 +346,7 @@ def test_to_dict_with_parameters(self, monkeypatch, calendar_event_model): "inputs_from_state": None, "name": "name", "outputs_to_state": None, + "cacheable": False, "outputs_to_string": None, "parameters": {"x": {"type": "string"}}, }, diff --git a/test/components/generators/chat/test_openai_responses.py b/test/components/generators/chat/test_openai_responses.py index e9a2ec2b9ae..ba34bc6f9fe 100644 --- a/test/components/generators/chat/test_openai_responses.py +++ b/test/components/generators/chat/test_openai_responses.py @@ -260,6 +260,7 @@ def test_to_dict_with_parameters(self, monkeypatch, calendar_event_model): "inputs_from_state": None, "name": "name", "outputs_to_state": None, + "cacheable": False, "outputs_to_string": None, "parameters": {"x": {"type": "string"}}, }, diff --git a/test/components/tools/test_tool_invoker.py b/test/components/tools/test_tool_invoker.py index f08519c1b45..286db6ccb81 100644 --- a/test/components/tools/test_tool_invoker.py +++ b/test/components/tools/test_tool_invoker.py @@ -33,6 +33,7 @@ ) from haystack.tools import ComponentTool, Tool, Toolset from haystack.tools.errors import ToolInvocationError +from haystack.tools.tool_cache import ToolCache def weather_function(location): @@ -360,6 +361,7 @@ def test_serde_in_pipeline(self, invoker, monkeypatch): "outputs_to_string": None, "inputs_from_state": None, "outputs_to_state": None, + "cacheable": False, }, } ], @@ -1469,3 +1471,83 @@ def warm_up(self): # After warmup: _tools_with_names should be refreshed with actual tool names assert "mcp_not_connected_placeholder_123" not in invoker._tools_with_names assert "get_time" in invoker._tools_with_names + + +class TestToolInvokerCaching: + def test_cacheable_tool_second_identical_call_is_served_from_cache(self): + call_count = [] + + def counting_weather_function(location): + call_count.append(location) + return weather_function(location) + + cacheable_weather_tool = Tool( + name="weather_tool", + description="Provides weather information for a given location.", + parameters=weather_parameters, + function=counting_weather_function, + cacheable=True, + ) + + cache = ToolCache() + invoker = ToolInvoker(tools=[cacheable_weather_tool], tool_cache=cache) + + tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}) + message = ChatMessage.from_assistant(tool_calls=[tool_call]) + + invoker.run(messages=[message]) + invoker.run(messages=[message]) + + assert len(call_count) == 1 + assert cache.stats.hits == 1 + assert cache.stats.misses == 1 + + def test_non_cacheable_tool_always_invokes(self, weather_tool): + call_count = [] + + def counting_weather_function(location): + call_count.append(location) + return weather_function(location) + + weather_tool.function = counting_weather_function + + cache = ToolCache() + invoker = ToolInvoker(tools=[weather_tool], tool_cache=cache) + + tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}) + message = ChatMessage.from_assistant(tool_calls=[tool_call]) + + invoker.run(messages=[message]) + invoker.run(messages=[message]) + + assert len(call_count) == 2 + assert cache.stats.hits == 0 + assert cache.stats.misses == 0 + + def test_cache_miss_on_different_arguments(self): + call_count = [] + + def counting_weather_function(location): + call_count.append(location) + return weather_function(location) + + cacheable_weather_tool = Tool( + name="weather_tool", + description="Provides weather information for a given location.", + parameters=weather_parameters, + function=counting_weather_function, + cacheable=True, + ) + + cache = ToolCache() + invoker = ToolInvoker(tools=[cacheable_weather_tool], tool_cache=cache) + + berlin_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}) + paris_call = ToolCall(tool_name="weather_tool", arguments={"location": "Paris"}) + + invoker.run(messages=[ChatMessage.from_assistant(tool_calls=[berlin_call])]) + invoker.run(messages=[ChatMessage.from_assistant(tool_calls=[paris_call])]) + + assert len(call_count) == 2 + assert cache.stats.misses == 2 + assert cache.stats.hits == 0 diff --git a/test/tools/test_component_tool.py b/test/tools/test_component_tool.py index a8f927398d1..1075a014ac9 100644 --- a/test/tools/test_component_tool.py +++ b/test/tools/test_component_tool.py @@ -890,6 +890,7 @@ def test_component_tool_serde(self): "outputs_to_string": {"source": "reply", "handler": "test_component_tool.reply_formatter"}, "inputs_from_state": {"test": "text"}, "outputs_to_state": {"output": {"source": "reply", "handler": "test_component_tool.output_handler"}}, + "cacheable": False, }, } tool_dict = tool.to_dict() diff --git a/test/tools/test_tool.py b/test/tools/test_tool.py index 3a8502aff18..de4492b829f 100644 --- a/test/tools/test_tool.py +++ b/test/tools/test_tool.py @@ -161,6 +161,7 @@ def test_to_dict(self): "outputs_to_string": {"handler": "test_tool.format_string"}, "inputs_from_state": {"location": "city"}, "outputs_to_state": {"documents": {"source": "docs", "handler": "test_tool.get_weather_report"}}, + "cacheable": False, }, } diff --git a/test/tools/test_tool_cache.py b/test/tools/test_tool_cache.py new file mode 100644 index 00000000000..70614d38c5a --- /dev/null +++ b/test/tools/test_tool_cache.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: 2022-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import time + +import pytest + +from haystack.tools.tool_cache import InMemoryToolCache, ToolCache, ToolCacheStats, make_cache_key + + +class TestToolCacheStats: + def test_default_hits_and_misses_are_zero(self): + stats = ToolCacheStats() + assert stats.hits == 0 + assert stats.misses == 0 + + def test_calls_saved_equals_hits(self): + stats = ToolCacheStats(hits=3, misses=2) + assert stats.calls_saved == 3 + + def test_to_dict(self): + stats = ToolCacheStats(hits=3, misses=2) + assert stats.to_dict() == {"hits": 3, "misses": 2, "calls_saved": 3} + + +class TestInMemoryToolCache: + def test_get_on_missing_key_returns_none(self): + backend = InMemoryToolCache() + assert backend.get("missing") is None + + def test_set_then_get_returns_value_and_stored_at(self): + backend = InMemoryToolCache() + backend.set("key", "value", 123.0) + assert backend.get("key") == ("value", 123.0) + + def test_clear_empties_store(self): + backend = InMemoryToolCache() + backend.set("key", "value", 123.0) + backend.clear() + assert backend.get("key") is None + + +class TestMakeCacheKey: + def test_same_args_different_key_order_produce_same_key(self): + key1 = make_cache_key("weather", {"city": "Berlin", "unit": "C"}) + key2 = make_cache_key("weather", {"unit": "C", "city": "Berlin"}) + assert key1 == key2 + + def test_different_args_produce_different_keys(self): + key1 = make_cache_key("weather", {"city": "Berlin"}) + key2 = make_cache_key("weather", {"city": "Paris"}) + assert key1 != key2 + + def test_non_json_serializable_args_fall_back_to_str(self): + class Unserializable: + def __str__(self): + return "unserializable-repr" + + key = make_cache_key("weather", {"obj": Unserializable()}) + assert isinstance(key, str) + + +class TestToolCache: + def test_init_invalid_scope_raises(self): + with pytest.raises(ValueError, match="scope must be one of"): + ToolCache(scope="invalid_scope") + + def test_init_non_positive_ttl_seconds_raises(self): + with pytest.raises(ValueError, match="ttl_seconds must be > 0"): + ToolCache(ttl_seconds=0) + + with pytest.raises(ValueError, match="ttl_seconds must be > 0"): + ToolCache(ttl_seconds=-1) + + def test_get_on_empty_cache_is_a_miss(self): + cache = ToolCache() + hit, value = cache.get("weather", {"city": "Berlin"}) + assert hit is False + assert value is None + assert cache.stats.misses == 1 + assert cache.stats.hits == 0 + + def test_set_then_get_within_ttl_is_a_hit(self): + cache = ToolCache(ttl_seconds=300) + cache.set("weather", {"city": "Berlin"}, "sunny") + + hit, value = cache.get("weather", {"city": "Berlin"}) + + assert hit is True + assert value == "sunny" + assert cache.stats.hits == 1 + assert cache.stats.misses == 0 + + def test_get_after_ttl_expiry_is_a_miss(self, monkeypatch): + cache = ToolCache(ttl_seconds=10) + + monkeypatch.setattr(time, "monotonic", lambda: 1000.0) + cache.set("weather", {"city": "Berlin"}, "sunny") + + monkeypatch.setattr(time, "monotonic", lambda: 1011.0) + hit, value = cache.get("weather", {"city": "Berlin"}) + + assert hit is False + assert value is None + assert cache.stats.misses == 1 + assert cache.stats.hits == 0 + + def test_clear_resets_backend_and_stats(self): + cache = ToolCache() + cache.set("weather", {"city": "Berlin"}, "sunny") + cache.get("weather", {"city": "Berlin"}) + cache.get("weather", {"city": "Paris"}) + + cache.clear() + + assert cache.stats.hits == 0 + assert cache.stats.misses == 0 + hit, value = cache.get("weather", {"city": "Berlin"}) + assert hit is False + assert value is None