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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
+132
View File
@@ -0,0 +1,132 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.components.agents.state import State
from haystack.dataclasses import ChatMessage
from haystack.hooks import FunctionHook, hook
def append_system(state: State) -> None:
state.set("messages", [ChatMessage.from_system("from sync hook")])
async def append_system_async(state: State) -> None:
state.set("messages", [ChatMessage.from_system("from async hook")])
def append_system_postponed_annotation(state: "State") -> None:
state.set("messages", [ChatMessage.from_system("from postponed annotation hook")])
class TestHookDecorator:
def test_wraps_sync_function(self):
wrapped = hook(append_system)
assert isinstance(wrapped, FunctionHook)
assert wrapped.function is append_system
assert wrapped.async_function is None
def test_wraps_async_function(self):
wrapped = hook(append_system_async)
assert isinstance(wrapped, FunctionHook)
assert wrapped.function is None
assert wrapped.async_function is append_system_async
def test_run_invokes_sync_function(self):
state = State(schema={})
hook(append_system).run(state)
assert [m.text for m in state.data["messages"]] == ["from sync hook"]
def test_wraps_function_with_postponed_state_annotation(self):
wrapped = hook(append_system_postponed_annotation)
state = State(schema={})
wrapped.run(state)
assert [m.text for m in state.data["messages"]] == ["from postponed annotation hook"]
def test_run_on_async_only_raises(self):
with pytest.raises(RuntimeError):
hook(append_system_async).run(State(schema={}))
class TestFunctionHookConstruction:
def test_requires_at_least_one_function(self):
with pytest.raises(ValueError):
FunctionHook()
def test_sync_slot_rejects_coroutine_function(self):
with pytest.raises(ValueError):
FunctionHook(function=append_system_async)
def test_async_slot_rejects_regular_function(self):
with pytest.raises(ValueError):
FunctionHook(async_function=append_system)
def test_rejects_function_without_a_parameter(self):
def no_params() -> None:
pass
with pytest.raises(ValueError):
FunctionHook(function=no_params)
def test_rejects_function_with_extra_parameters(self):
def two_params(state: State, extra: int) -> None:
pass
with pytest.raises(ValueError):
FunctionHook(function=two_params)
def test_rejects_unannotated_parameter(self):
def unannotated(state) -> None:
pass
with pytest.raises(ValueError):
FunctionHook(function=unannotated)
def test_both_functions(self):
h = FunctionHook(function=append_system, async_function=append_system_async)
state = State(schema={})
h.run(state)
assert [m.text for m in state.data["messages"]] == ["from sync hook"]
class TestFunctionHookSerde:
def test_to_dict_sync(self):
data = hook(append_system).to_dict()
assert data == {
"type": "haystack.hooks.from_function.FunctionHook",
"init_parameters": {"function": "test.hooks.test_from_function.append_system", "async_function": None},
}
def test_roundtrip_sync(self):
restored = FunctionHook.from_dict(hook(append_system).to_dict())
state = State(schema={})
restored.run(state)
assert [m.text for m in state.data["messages"]] == ["from sync hook"]
def test_roundtrip_async(self):
restored = FunctionHook.from_dict(hook(append_system_async).to_dict())
assert restored.function is None
assert restored.async_function is append_system_async
def test_roundtrip_both(self):
restored = FunctionHook.from_dict(
FunctionHook(function=append_system, async_function=append_system_async).to_dict()
)
assert restored.function is append_system
assert restored.async_function is append_system_async
class TestFunctionHookAsync:
@pytest.mark.asyncio
async def test_run_async_awaits_async_function(self):
state = State(schema={})
await hook(append_system_async).run_async(state)
assert [m.text for m in state.data["messages"]] == ["from async hook"]
@pytest.mark.asyncio
async def test_run_async_falls_back_to_sync_function(self):
state = State(schema={})
await hook(append_system).run_async(state)
assert [m.text for m in state.data["messages"]] == ["from sync hook"]
+87
View File
@@ -0,0 +1,87 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import threading
import pytest
from haystack.components.agents.state import State
from haystack.hooks.invocation import _run_hooks, _run_hooks_async
class RecordingHook:
"""Sync-only hook (no `run_async`), to exercise the async fallback path."""
def __init__(self, label: str, log: list) -> None:
self.label = label
self.log = log
def run(self, state: State) -> None:
self.log.append(("run", self.label))
class ThreadRecordingHook:
def __init__(self) -> None:
self.thread_id: int | None = None
def run(self, state: State) -> None:
self.thread_id = threading.get_ident()
class AsyncRecordingHook:
def __init__(self, label: str, log: list) -> None:
self.label = label
self.log = log
def run(self, state: State) -> None:
self.log.append(("run", self.label))
async def run_async(self, state: State) -> None:
self.log.append(("run_async", self.label))
class TestRunHooks:
def test_runs_all_hooks_for_hook_point_in_order(self):
log: list = []
hooks = {"before_llm": [RecordingHook("a", log), RecordingHook("b", log)]}
_run_hooks(hooks, "before_llm", State(schema={}))
assert log == [("run", "a"), ("run", "b")]
def test_only_runs_the_given_hook_point(self):
log: list = []
hooks = {"before_llm": [RecordingHook("a", log)], "on_exit": [RecordingHook("b", log)]}
_run_hooks(hooks, "on_exit", State(schema={}))
assert log == [("run", "b")]
def test_no_hooks_for_hook_point_is_noop(self):
_run_hooks({}, "before_llm", State(schema={})) # does not raise
class TestRunHooksAsync:
@pytest.mark.asyncio
async def test_awaits_run_async_when_present(self):
log: list = []
await _run_hooks_async({"before_llm": [AsyncRecordingHook("a", log)]}, "before_llm", State(schema={}))
assert log == [("run_async", "a")]
@pytest.mark.asyncio
async def test_falls_back_to_run_when_no_run_async(self):
log: list = []
await _run_hooks_async({"before_llm": [RecordingHook("a", log)]}, "before_llm", State(schema={}))
assert log == [("run", "a")]
@pytest.mark.asyncio
async def test_falls_back_to_run_in_worker_thread(self):
hook = ThreadRecordingHook()
event_loop_thread_id = threading.get_ident()
await _run_hooks_async({"before_llm": [hook]}, "before_llm", State(schema={}))
assert hook.thread_id is not None
assert hook.thread_id != event_loop_thread_id
@pytest.mark.asyncio
async def test_runs_in_order_mixing_sync_and_async(self):
log: list = []
hooks = {"before_llm": [AsyncRecordingHook("a", log), RecordingHook("b", log)]}
await _run_hooks_async(hooks, "before_llm", State(schema={}))
assert log == [("run_async", "a"), ("run", "b")]
+144
View File
@@ -0,0 +1,144 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.components.agents.state import State
from haystack.hooks import FunctionHook, hook
from haystack.hooks.utils import (
_deserialize_hooks_dictionary,
_serialize_hooks_dictionary,
_unique_hooks,
close_hooks,
close_hooks_async,
warm_up_hooks,
warm_up_hooks_async,
)
def noop(state: State) -> None:
pass
class LifecycleSpy:
"""Hook recording its lifecycle calls."""
def __init__(self) -> None:
self.warmed = 0
self.warmed_async = 0
self.closed = 0
self.closed_async = 0
def run(self, state: State) -> None:
pass
def warm_up(self) -> None:
self.warmed += 1
async def warm_up_async(self) -> None:
self.warmed_async += 1
def close(self) -> None:
self.closed += 1
async def close_async(self) -> None:
self.closed_async += 1
class WarmOnlyHook:
"""Hook with a sync `warm_up`/`close` but no async variants."""
def __init__(self) -> None:
self.warmed = 0
self.closed = 0
def run(self, state: State) -> None:
pass
def warm_up(self) -> None:
self.warmed += 1
def close(self) -> None:
self.closed += 1
class PlainHook:
"""Hook with no lifecycle methods."""
def run(self, state: State) -> None:
pass
class TestUniqueHooks:
def test_dedupes_by_identity_preserving_order(self):
a, b = PlainHook(), PlainHook()
unique = _unique_hooks({"before_llm": [a, b], "on_exit": [a]})
assert unique == [a, b]
def test_empty(self):
assert _unique_hooks({}) == []
class TestSerializeHooks:
def test_roundtrip_multiple_hook_points(self):
hooks = {"before_llm": [hook(noop)], "on_exit": [hook(noop)]}
restored = _deserialize_hooks_dictionary(_serialize_hooks_dictionary(hooks))
assert set(restored) == {"before_llm", "on_exit"}
assert all(isinstance(h, FunctionHook) for hook_list in restored.values() for h in hook_list)
assert restored["before_llm"][0].function is noop
class TestWarmUpHooks:
def test_calls_warm_up_when_present(self):
spy = LifecycleSpy()
warm_up_hooks({"before_llm": [spy]})
assert spy.warmed == 1
def test_skips_hooks_without_warm_up(self):
warm_up_hooks({"before_llm": [PlainHook()]}) # does not raise
def test_reused_hook_warmed_once(self):
spy = LifecycleSpy()
warm_up_hooks({"before_llm": [spy], "on_exit": [spy]})
assert spy.warmed == 1
class TestCloseHooks:
def test_calls_close_when_present(self):
spy = LifecycleSpy()
close_hooks({"before_llm": [spy]})
assert spy.closed == 1
def test_skips_hooks_without_close(self):
close_hooks({"before_llm": [PlainHook()]}) # does not raise
class TestWarmUpHooksAsync:
@pytest.mark.asyncio
async def test_prefers_async_warm_up(self):
spy = LifecycleSpy()
await warm_up_hooks_async({"before_llm": [spy]})
assert spy.warmed_async == 1
assert spy.warmed == 0
@pytest.mark.asyncio
async def test_falls_back_to_sync_warm_up(self):
hook_obj = WarmOnlyHook()
await warm_up_hooks_async({"before_llm": [hook_obj]})
assert hook_obj.warmed == 1
class TestCloseHooksAsync:
@pytest.mark.asyncio
async def test_prefers_async_close(self):
spy = LifecycleSpy()
await close_hooks_async({"before_llm": [spy]})
assert spy.closed_async == 1
assert spy.closed == 0
@pytest.mark.asyncio
async def test_falls_back_to_sync_close(self):
hook_obj = WarmOnlyHook()
await close_hooks_async({"before_llm": [hook_obj]})
assert hook_obj.closed == 1
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,271 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from pathlib import Path
from typing import Annotated, Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from haystack import component
from haystack.components.agents import Agent
from haystack.components.agents.state.state import State
from haystack.dataclasses import ChatMessage, ImageContent, TextContent, ToolCall
from haystack.hooks.tool_result_offloading import (
RESULT_STORE_CONTEXT_KEY,
AlwaysOffload,
FileSystemToolResultStore,
NeverOffload,
OffloadOverChars,
ToolResultOffloadHook,
)
from haystack.tools import Tool, Toolset, tool
@component
class MockChatGenerator:
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("done")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("done")]}
@tool
def big_tool(query: Annotated[str, "the query"]) -> str:
"""Return a large result."""
return "R" * 500
def _state_with_messages(messages: list[ChatMessage]) -> State:
return State(schema={"messages": {"type": list[ChatMessage]}}, data={"messages": messages, "step_count": 1})
def _tool_message(tool_name: str, result: str, *, error: bool = False, call_id: str = "c1") -> ChatMessage:
return ChatMessage.from_tool(
tool_result=result, origin=ToolCall(tool_name=tool_name, arguments={}, id=call_id), error=error
)
class TestToolResultOffloadHookRouting:
def test_exact_tuple_and_wildcard_keys(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path),
offload_strategies={
"a": AlwaysOffload(),
("b", "c"): AlwaysOffload(),
"d": NeverOffload(),
"*": AlwaysOffload(),
},
)
state = _state_with_messages(
[
_tool_message("a", "A" * 50, call_id="1"), # exact -> offload
_tool_message("b", "B" * 50, call_id="2"), # tuple -> offload
_tool_message("d", "D" * 50, call_id="3"), # exact NeverOffload -> keep
_tool_message("z", "Z" * 50, call_id="4"), # wildcard -> offload
]
)
hook.run(state)
results = [m.tool_call_result.result for m in state.data["messages"]]
assert results[0].startswith("Tool result offloaded")
assert results[1].startswith("Tool result offloaded")
assert results[2] == "D" * 50
assert results[3].startswith("Tool result offloaded")
def test_tool_without_matching_key_is_not_offloaded(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path), offload_strategies={"a": AlwaysOffload()}
)
state = _state_with_messages([_tool_message("b", "B" * 50)])
hook.run(state)
assert state.data["messages"][0].tool_call_result.result == "B" * 50
def test_over_chars_threshold(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path), offload_strategies={"*": OffloadOverChars(10)}
)
state = _state_with_messages(
[_tool_message("a", "x" * 10, call_id="1"), _tool_message("a", "x" * 11, call_id="2")]
)
hook.run(state)
results = [m.tool_call_result.result for m in state.data["messages"]]
assert results[0] == "x" * 10
assert results[1].startswith("Tool result offloaded")
class TestToolResultOffloadHookBehavior:
def test_error_results_are_never_offloaded(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path), offload_strategies={"*": AlwaysOffload()}
)
state = _state_with_messages([_tool_message("a", "boom", error=True)])
hook.run(state)
assert state.data["messages"][0].tool_call_result.result == "boom"
def test_offloads_sequence_of_text_content(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path)
hook = ToolResultOffloadHook(store=store, offload_strategies={"*": AlwaysOffload()})
message = ChatMessage.from_tool(
tool_result=[TextContent("A" * 30), TextContent("B" * 30)],
origin=ToolCall(tool_name="a", arguments={}, id="1"),
)
state = _state_with_messages([message])
hook.run(state)
offloaded = state.data["messages"][0]
assert offloaded.tool_call_result.result.startswith("Tool result offloaded")
assert "60 characters" in offloaded.tool_call_result.result
assert store.read(offloaded.meta["tool_result_offloaded"]) == "A" * 30 + "B" * 30
def test_result_with_image_content_is_not_offloaded(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path), offload_strategies={"*": AlwaysOffload()}
)
content = [TextContent("caption"), ImageContent(base64_image="aGVsbG8=", mime_type="image/png")]
message = ChatMessage.from_tool(tool_result=content, origin=ToolCall(tool_name="a", arguments={}, id="1"))
state = _state_with_messages([message])
hook.run(state)
assert state.data["messages"][0].tool_call_result.result == content
assert not list(Path(tmp_path).iterdir())
def test_id_less_parallel_calls_do_not_collide(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path)
hook = ToolResultOffloadHook(store=store, offload_strategies={"*": AlwaysOffload()})
first = ChatMessage.from_tool(tool_result="FIRST" * 20, origin=ToolCall(tool_name="a", arguments={}, id=None))
second = ChatMessage.from_tool(tool_result="SECOND" * 20, origin=ToolCall(tool_name="a", arguments={}, id=None))
state = _state_with_messages([first, second])
hook.run(state)
refs = [m.meta["tool_result_offloaded"] for m in state.data["messages"]]
assert refs[0] != refs[1]
assert store.read(refs[0]) == "FIRST" * 20
assert store.read(refs[1]) == "SECOND" * 20
assert len(list(Path(tmp_path).iterdir())) == 2
def test_only_trailing_tool_results_are_offloaded(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path)
hook = ToolResultOffloadHook(store=store, offload_strategies={"*": AlwaysOffload()})
# A tool result from a prior turn, then an assistant message, then this step's fresh tool result.
history = _tool_message("old", "H" * 50, call_id="old1")
assistant = ChatMessage.from_assistant(tool_calls=[ToolCall("a", {}, id="c1")])
fresh = _tool_message("a", "F" * 50, call_id="c1")
state = _state_with_messages([history, assistant, fresh])
hook.run(state)
out = state.data["messages"]
assert out[0].tool_call_result.result == "H" * 50
assert out[2].tool_call_result.result.startswith("Tool result offloaded")
assert len(list(Path(tmp_path).iterdir())) == 1
def test_second_offload_hook_does_not_reoffload_pointer(self, tmp_path):
# Two offload hooks under `after_tool` run in sequence on the same state. The first offloads the result and
# marks it; the `_OFFLOADED_META_KEY` marker stops the second from offloading the pointer text again.
store = FileSystemToolResultStore(root=tmp_path)
first_hook = ToolResultOffloadHook(store=store, offload_strategies={"*": AlwaysOffload()})
second_hook = ToolResultOffloadHook(store=store, offload_strategies={"*": AlwaysOffload()})
state = _state_with_messages([_tool_message("a", "A" * 50)])
first_hook.run(state)
pointer = state.data["messages"][0].tool_call_result.result
second_hook.run(state)
assert state.data["messages"][0].tool_call_result.result == pointer
assert len(list(Path(tmp_path).iterdir())) == 1
def test_pointer_contains_reference_size_and_preview(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path), offload_strategies={"*": AlwaysOffload()}, preview_chars=5
)
state = _state_with_messages([_tool_message("a", "ABCDEFGH")])
hook.run(state)
message = state.data["messages"][0]
reference = message.meta["tool_result_offloaded"]
pointer = message.tool_call_result.result
assert reference in pointer
assert "8 characters" in pointer
assert "ABCDE..." in pointer
def test_concurrent_runs_are_isolated_by_per_run_store(self, tmp_path):
# One shared hook instance, two runs each supplying its own store via hook_context. Even with identical store
# keys (same tool, id and step), each run writes to and reads from its own store — no cross-run collision.
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path / "shared"), offload_strategies={"*": AlwaysOffload()}
)
store_a = FileSystemToolResultStore(root=tmp_path / "a")
store_b = FileSystemToolResultStore(root=tmp_path / "b")
state_a = _state_with_messages([_tool_message("t", "AAA" * 20, call_id="x")])
state_a.data["hook_context"] = {RESULT_STORE_CONTEXT_KEY: store_a}
state_b = _state_with_messages([_tool_message("t", "BBB" * 20, call_id="x")])
state_b.data["hook_context"] = {RESULT_STORE_CONTEXT_KEY: store_b}
hook.run(state_a)
hook.run(state_b)
ref_a = state_a.data["messages"][0].meta["tool_result_offloaded"]
ref_b = state_b.data["messages"][0].meta["tool_result_offloaded"]
assert store_a.read(ref_a) == "AAA" * 20
assert store_b.read(ref_b) == "BBB" * 20
assert not (tmp_path / "shared").exists()
def test_hook_context_store_overrides_constructor_store(self, tmp_path):
default_store = FileSystemToolResultStore(root=tmp_path / "default")
request_store = FileSystemToolResultStore(root=tmp_path / "request")
hook = ToolResultOffloadHook(store=default_store, offload_strategies={"*": AlwaysOffload()})
state = _state_with_messages([_tool_message("a", "A" * 50)])
state.data["hook_context"] = {RESULT_STORE_CONTEXT_KEY: request_store}
hook.run(state)
assert (tmp_path / "request").exists()
assert not (tmp_path / "default").exists()
class TestToolResultOffloadHookSerde:
def test_to_dict_from_dict_roundtrip(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path),
offload_strategies={"a": AlwaysOffload(), ("b", "c"): OffloadOverChars(100), "*": NeverOffload()},
preview_chars=42,
)
restored = ToolResultOffloadHook.from_dict(hook.to_dict())
assert restored.preview_chars == 42
assert isinstance(restored.store, FileSystemToolResultStore)
assert set(restored.offload_strategies) == {"a", ("b", "c"), "*"}
assert isinstance(restored.offload_strategies[("b", "c")], OffloadOverChars)
assert restored.offload_strategies[("b", "c")].threshold == 100
class TestToolResultOffloadHookInAgent:
def test_offloads_tool_result_seen_by_next_llm_call(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path), offload_strategies={"*": AlwaysOffload()}
)
agent = Agent(chat_generator=MockChatGenerator(), tools=[big_tool], hooks={"after_tool": [hook]})
agent.warm_up()
agent.chat_generator.run = MagicMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("big_tool", {"query": "x"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
agent.run(messages=[ChatMessage.from_user("hi")])
second_call_messages = agent.chat_generator.run.call_args_list[1].kwargs["messages"]
offloaded = [m for m in second_call_messages if m.tool_call_result is not None]
assert offloaded[0].tool_call_result.result.startswith("Tool result offloaded")
assert len(list(Path(tmp_path).iterdir())) == 1
class TestToolResultOffloadHookInAgentAsync:
@pytest.mark.asyncio
async def test_offloads_tool_result_async(self, tmp_path):
hook = ToolResultOffloadHook(
store=FileSystemToolResultStore(root=tmp_path), offload_strategies={"*": AlwaysOffload()}
)
agent = Agent(chat_generator=MockChatGenerator(), tools=[big_tool], hooks={"after_tool": [hook]})
agent.warm_up()
agent.chat_generator.run_async = AsyncMock(
side_effect=[
{"replies": [ChatMessage.from_assistant(tool_calls=[ToolCall("big_tool", {"query": "x"})])]},
{"replies": [ChatMessage.from_assistant("done")]},
]
)
result = await agent.run_async(messages=[ChatMessage.from_user("hi")])
offloaded = [m for m in result["messages"] if m.tool_call_result is not None]
assert offloaded[0].tool_call_result.result.startswith("Tool result offloaded")
@@ -0,0 +1,23 @@
# 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.tool_result_offloading import AlwaysOffload, NeverOffload, OffloadOverChars
class TestOffloadPolicies:
def test_always_offload(self):
assert AlwaysOffload().should_offload("t", "anything", State(schema={})) is True
def test_never_offload(self):
assert NeverOffload().should_offload("t", "x" * 10_000, State(schema={})) is False
def test_offload_over_chars_is_strictly_greater(self):
policy = OffloadOverChars(threshold=10)
assert policy.should_offload("t", "x" * 10, State(schema={})) is False
assert policy.should_offload("t", "x" * 11, State(schema={})) is True
def test_offload_over_chars_roundtrip(self):
restored = OffloadOverChars.from_dict(OffloadOverChars(threshold=42).to_dict())
assert restored.threshold == 42
@@ -0,0 +1,48 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from pathlib import Path
import pytest
from haystack.hooks.tool_result_offloading import FileSystemToolResultStore
class TestFileSystemToolResultStore:
def test_write_returns_path_and_persists_content(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path)
reference = store.write(key="a.txt", content="hello")
assert reference == str(tmp_path / "a.txt")
assert Path(reference).read_text(encoding="utf-8") == "hello"
def test_write_creates_missing_directories(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path / "nested" / "dir")
reference = store.write(key="a.txt", content="hi")
assert Path(reference).read_text(encoding="utf-8") == "hi"
def test_write_allows_nested_keys_within_root(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path)
reference = store.write(key="sub/dir/a.txt", content="ok")
assert Path(reference).read_text(encoding="utf-8") == "ok"
def test_write_rejects_parent_traversal_key(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path / "root")
with pytest.raises(ValueError, match="outside the store root"):
store.write(key="../escape.txt", content="x")
assert not (tmp_path / "escape.txt").exists()
def test_write_rejects_absolute_key(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path / "root")
with pytest.raises(ValueError, match="outside the store root"):
store.write(key=str(tmp_path / "outside.txt"), content="x")
def test_read_round_trips_written_content(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path)
reference = store.write(key="a.txt", content="round trip")
assert store.read(reference) == "round trip"
def test_to_dict_from_dict_roundtrip(self, tmp_path):
store = FileSystemToolResultStore(root=tmp_path)
restored = FileSystemToolResultStore.from_dict(store.to_dict())
assert restored.root == Path(tmp_path)