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,3 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -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"]
|
||||
@@ -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")]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user