c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
359 lines
16 KiB
Python
359 lines
16 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import threading
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from haystack.components.agents.state.state import State
|
|
from haystack.components.agents.state.state_utils import merge_lists, replace_values
|
|
from haystack.dataclasses import ChatMessage, ToolCall
|
|
from haystack.human_in_the_loop import (
|
|
AlwaysAskPolicy,
|
|
BlockingConfirmationStrategy,
|
|
ConfirmationHook,
|
|
ConfirmationUIResult,
|
|
NeverAskPolicy,
|
|
SimpleConsoleUI,
|
|
ToolExecutionDecision,
|
|
)
|
|
from haystack.human_in_the_loop.types import ConfirmationUI
|
|
from haystack.tools import Tool, create_tool_from_function
|
|
|
|
|
|
class MockUserInterface(ConfirmationUI):
|
|
def __init__(self, ui_result: ConfirmationUIResult) -> None:
|
|
self.ui_result = ui_result
|
|
|
|
def get_user_confirmation(
|
|
self, tool_name: str, tool_description: str, tool_params: dict[str, Any]
|
|
) -> ConfirmationUIResult:
|
|
return self.ui_result
|
|
|
|
|
|
def addition_tool(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
|
|
@pytest.fixture
|
|
def tools() -> list[Tool]:
|
|
return [
|
|
create_tool_from_function(
|
|
function=addition_tool, name="addition_tool", description="A tool that adds two integers together."
|
|
)
|
|
]
|
|
|
|
|
|
def _state_with(messages: list[ChatMessage], tools: list[Tool]) -> State:
|
|
schema = {
|
|
"messages": {"type": list[ChatMessage], "handler": merge_lists},
|
|
"tools": {"type": list, "handler": replace_values},
|
|
}
|
|
state = State(schema=schema, data={})
|
|
state.set("messages", messages, handler_override=replace_values)
|
|
state.set("tools", tools, handler_override=replace_values)
|
|
return state
|
|
|
|
|
|
def _confirm_strat(ui_result: ConfirmationUIResult) -> BlockingConfirmationStrategy:
|
|
return BlockingConfirmationStrategy(
|
|
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=MockUserInterface(ui_result)
|
|
)
|
|
|
|
|
|
def _confirm_hook(ui_result: ConfirmationUIResult, tool_name: str = "addition_tool") -> ConfirmationHook:
|
|
return ConfirmationHook(confirmation_strategies={tool_name: _confirm_strat(ui_result)})
|
|
|
|
|
|
class TestConfirmationHook:
|
|
def test_no_tool_calls_is_noop(self, tools):
|
|
messages = [ChatMessage.from_user("hello"), ChatMessage.from_assistant("hi")]
|
|
state = _state_with(messages, tools)
|
|
hook = _confirm_hook(ConfirmationUIResult(action="reject"))
|
|
hook.run(state)
|
|
assert state.get("messages") == messages
|
|
|
|
def test_confirm_keeps_tool_call(self, tools):
|
|
tool_call = ToolCall("addition_tool", {"a": 1, "b": 2})
|
|
messages = [ChatMessage.from_user("add"), ChatMessage.from_assistant(tool_calls=[tool_call])]
|
|
state = _state_with(messages, tools)
|
|
# NeverAskPolicy auto-confirms, so the tool call is left untouched for the executor.
|
|
hook = ConfirmationHook(
|
|
confirmation_strategies={
|
|
"addition_tool": BlockingConfirmationStrategy(
|
|
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
|
|
)
|
|
}
|
|
)
|
|
hook.run(state)
|
|
assert state.get("messages")[-1].tool_calls == [tool_call]
|
|
|
|
def test_modify_updates_arguments(self, tools):
|
|
messages = [
|
|
ChatMessage.from_user("add"),
|
|
ChatMessage.from_assistant(tool_calls=[ToolCall("addition_tool", {"a": 1, "b": 2})]),
|
|
]
|
|
state = _state_with(messages, tools)
|
|
hook = _confirm_hook(ConfirmationUIResult(action="modify", new_tool_params={"a": 10, "b": 20}))
|
|
hook.run(state)
|
|
# The original call is dropped; an explanation user message precedes the rebuilt call with the new arguments.
|
|
explanation = "The parameters for tool 'addition_tool' were updated by the user to:\n{'a': 10, 'b': 20}"
|
|
assert state.get("messages") == [
|
|
ChatMessage.from_user("add"),
|
|
ChatMessage.from_user(explanation),
|
|
ChatMessage.from_assistant(tool_calls=[ToolCall("addition_tool", {"a": 10, "b": 20})]),
|
|
]
|
|
|
|
def test_reject_drops_tool_call_and_appends_result(self, tools):
|
|
messages = [
|
|
ChatMessage.from_user("add"),
|
|
ChatMessage.from_assistant(tool_calls=[ToolCall("addition_tool", {"a": 1, "b": 2})]),
|
|
]
|
|
state = _state_with(messages, tools)
|
|
hook = _confirm_hook(ConfirmationUIResult(action="reject"))
|
|
hook.run(state)
|
|
# The call is answered by an error tool result, so it is resolved and the executor skips it (no pending call).
|
|
assert state.get("messages") == [
|
|
ChatMessage.from_user("add"),
|
|
ChatMessage.from_assistant(tool_calls=[ToolCall("addition_tool", {"a": 1, "b": 2})]),
|
|
ChatMessage.from_tool(
|
|
tool_result="Tool execution for 'addition_tool' was rejected by the user.",
|
|
origin=ToolCall("addition_tool", {"a": 1, "b": 2}),
|
|
error=True,
|
|
),
|
|
]
|
|
|
|
def test_hook_context_is_not_deepcopied(self, tools):
|
|
# A non-copyable resource in hook_context (e.g. a lock, WebSocket, or client) must reach the strategy
|
|
# unchanged. Reading via state.get would deepcopy and raise; the hook reads via state.data instead.
|
|
lock = threading.Lock()
|
|
received = {}
|
|
|
|
class CapturingStrategy:
|
|
def run(
|
|
self, *, tool_name, tool_description, tool_params, tool_call_id=None, confirmation_strategy_context=None
|
|
):
|
|
received["context"] = confirmation_strategy_context
|
|
return ToolExecutionDecision(
|
|
tool_name=tool_name, tool_call_id=tool_call_id, execute=True, final_tool_params=tool_params
|
|
)
|
|
|
|
messages = [
|
|
ChatMessage.from_user("add"),
|
|
ChatMessage.from_assistant(tool_calls=[ToolCall("addition_tool", {"a": 1, "b": 2})]),
|
|
]
|
|
state = _state_with(messages, tools)
|
|
state.data["hook_context"] = {"resource": lock}
|
|
ConfirmationHook(confirmation_strategies={"addition_tool": CapturingStrategy()}).run(state) # type: ignore[dict-item]
|
|
# Passed through by identity: the non-copyable resource is neither copied nor does the read raise.
|
|
assert received["context"]["resource"] is lock
|
|
|
|
def test_to_dict(self):
|
|
hook = ConfirmationHook(
|
|
confirmation_strategies={
|
|
"addition_tool": BlockingConfirmationStrategy(
|
|
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
|
|
)
|
|
}
|
|
)
|
|
assert hook.to_dict() == {
|
|
"type": "haystack.human_in_the_loop.hooks.ConfirmationHook",
|
|
"init_parameters": {
|
|
"confirmation_strategies": {
|
|
"addition_tool": {
|
|
"type": "haystack.human_in_the_loop.strategies.BlockingConfirmationStrategy",
|
|
"init_parameters": {
|
|
"confirmation_policy": {
|
|
"type": "haystack.human_in_the_loop.policies.NeverAskPolicy",
|
|
"init_parameters": {},
|
|
},
|
|
"confirmation_ui": {
|
|
"type": "haystack.human_in_the_loop.user_interfaces.SimpleConsoleUI",
|
|
"init_parameters": {},
|
|
},
|
|
"reject_template": "Tool execution for '{tool_name}' was rejected by the user.",
|
|
"modify_template": "The parameters for tool '{tool_name}' were updated by the user to:"
|
|
"\n{final_tool_params}",
|
|
"user_feedback_template": "With user feedback: {feedback}",
|
|
},
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
def test_from_dict_round_trip(self):
|
|
hook = ConfirmationHook(
|
|
confirmation_strategies={
|
|
"addition_tool": BlockingConfirmationStrategy(
|
|
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
|
|
)
|
|
}
|
|
)
|
|
deserialized = ConfirmationHook.from_dict(hook.to_dict())
|
|
assert deserialized.to_dict() == hook.to_dict()
|
|
strategy = deserialized.confirmation_strategies["addition_tool"]
|
|
assert isinstance(strategy, BlockingConfirmationStrategy)
|
|
assert isinstance(strategy.confirmation_policy, NeverAskPolicy)
|
|
assert isinstance(strategy.confirmation_ui, SimpleConsoleUI)
|
|
|
|
def test_tuple_key_round_trips(self):
|
|
hook = ConfirmationHook(
|
|
confirmation_strategies={
|
|
("addition_tool", "other_tool"): BlockingConfirmationStrategy(
|
|
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
|
|
)
|
|
}
|
|
)
|
|
deserialized = ConfirmationHook.from_dict(hook.to_dict())
|
|
assert ("addition_tool", "other_tool") in deserialized.confirmation_strategies
|
|
|
|
|
|
class TestConfirmationHookWildcard:
|
|
def test_wildcard_applies_to_any_tool(self, tools):
|
|
# No entry for "addition_tool"; the "*" wildcard covers it.
|
|
messages = [
|
|
ChatMessage.from_user("add"),
|
|
ChatMessage.from_assistant(tool_calls=[ToolCall(id="tc-1", tool_name="addition_tool", arguments={})]),
|
|
]
|
|
state = _state_with(messages, tools)
|
|
_confirm_hook(ConfirmationUIResult(action="reject"), tool_name="*").run(state)
|
|
|
|
new_messages = state.get("messages")
|
|
assert new_messages[-1].tool_call_result is not None
|
|
assert "rejected" in new_messages[-1].tool_call_result.result.lower()
|
|
|
|
def test_specific_key_beats_wildcard(self):
|
|
add_tool = create_tool_from_function(
|
|
function=addition_tool, name="addition_tool", description="Adds two integers."
|
|
)
|
|
other_tool = create_tool_from_function(function=addition_tool, name="other_tool", description="Another tool.")
|
|
messages = [
|
|
ChatMessage.from_user("go"),
|
|
ChatMessage.from_assistant(
|
|
tool_calls=[
|
|
ToolCall(id="tc-add", tool_name="addition_tool", arguments={"a": 1, "b": 2}),
|
|
ToolCall(id="tc-other", tool_name="other_tool", arguments={"a": 3, "b": 4}),
|
|
]
|
|
),
|
|
]
|
|
state = _state_with(messages, [add_tool, other_tool])
|
|
# addition_tool has its own (confirm) entry; other_tool falls through to the "*" (reject) wildcard.
|
|
hook = ConfirmationHook(
|
|
confirmation_strategies={
|
|
"addition_tool": _confirm_strat(ConfirmationUIResult(action="confirm")),
|
|
"*": _confirm_strat(ConfirmationUIResult(action="reject")),
|
|
}
|
|
)
|
|
hook.run(state)
|
|
|
|
new_messages = state.get("messages")
|
|
# addition_tool is confirmed, so it stays pending on the last message; other_tool is rejected.
|
|
assert [tc.tool_name for tc in new_messages[-1].tool_calls] == ["addition_tool"]
|
|
rejected = [m for m in new_messages if m.tool_call_result is not None]
|
|
assert [m.tool_call_result.origin.tool_name for m in rejected] == ["other_tool"]
|
|
assert rejected[0].tool_call_result.error is True
|
|
|
|
|
|
class TestConfirmationHookAsync:
|
|
@pytest.mark.asyncio
|
|
async def test_reject_drops_tool_call_and_appends_result(self, tools):
|
|
messages = [
|
|
ChatMessage.from_user("add"),
|
|
ChatMessage.from_assistant(tool_calls=[ToolCall("addition_tool", {"a": 1, "b": 2})]),
|
|
]
|
|
state = _state_with(messages, tools)
|
|
hook = _confirm_hook(ConfirmationUIResult(action="reject"))
|
|
await hook.run_async(state)
|
|
# run_async produces the same transcript as run: the call answered by an error tool result, no pending call.
|
|
assert state.get("messages") == [
|
|
ChatMessage.from_user("add"),
|
|
ChatMessage.from_assistant(tool_calls=[ToolCall("addition_tool", {"a": 1, "b": 2})]),
|
|
ChatMessage.from_tool(
|
|
tool_result="Tool execution for 'addition_tool' was rejected by the user.",
|
|
origin=ToolCall("addition_tool", {"a": 1, "b": 2}),
|
|
error=True,
|
|
),
|
|
]
|
|
|
|
|
|
def _echo(x: int) -> dict[str, int]:
|
|
return {"echoed": x}
|
|
|
|
|
|
def _echo_tool(name: str) -> Tool:
|
|
return Tool(
|
|
name=name,
|
|
description=f"Echo tool {name}.",
|
|
parameters={"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]},
|
|
function=_echo,
|
|
)
|
|
|
|
|
|
def _multi_tool_hook(decisions: dict[str, ConfirmationUIResult]) -> ConfirmationHook:
|
|
return ConfirmationHook(
|
|
confirmation_strategies={name: _confirm_strat(ui_result) for name, ui_result in decisions.items()}
|
|
)
|
|
|
|
|
|
def _assert_pending_calls_only_in_last_message(messages: list[ChatMessage]) -> None:
|
|
"""
|
|
Assert the invariant the Agent loop relies on: after confirmation, every tool call that is still pending (an
|
|
assistant tool call with no matching tool result later in the history) lives in the last message. The loop reads
|
|
the calls to execute from the last message alone, so any leftover pending call elsewhere would silently never run.
|
|
"""
|
|
resolved_ids = {m.tool_call_result.origin.id for m in messages if m.tool_call_result is not None}
|
|
for index, message in enumerate(messages):
|
|
pending = [tc for tc in (message.tool_calls or []) if tc.id not in resolved_ids]
|
|
if pending:
|
|
assert index == len(messages) - 1, f"pending calls in non-last message at index {index}: {pending}"
|
|
|
|
|
|
class TestConfirmationHookPendingInvariant:
|
|
"""The pending tool calls left after the hook runs must always sit in the last message of the chat history."""
|
|
|
|
def _run(self, decisions: dict[str, ConfirmationUIResult]) -> list[ChatMessage]:
|
|
names = list(decisions)
|
|
tools = [_echo_tool(name) for name in names]
|
|
tool_calls = [ToolCall(id=f"tc-{name}", tool_name=name, arguments={"x": i}) for i, name in enumerate(names)]
|
|
messages = [ChatMessage.from_user("go"), ChatMessage.from_assistant(tool_calls=tool_calls)]
|
|
state = _state_with(messages, tools)
|
|
_multi_tool_hook(decisions).run(state)
|
|
return state.get("messages")
|
|
|
|
def test_all_confirmed(self):
|
|
messages = self._run(
|
|
{"foo": ConfirmationUIResult(action="confirm"), "bar": ConfirmationUIResult(action="confirm")}
|
|
)
|
|
_assert_pending_calls_only_in_last_message(messages)
|
|
# Both calls survive, untouched, on the (single) last assistant message.
|
|
assert {tc.tool_name for tc in messages[-1].tool_calls} == {"foo", "bar"}
|
|
|
|
def test_all_rejected(self):
|
|
messages = self._run(
|
|
{"foo": ConfirmationUIResult(action="reject"), "bar": ConfirmationUIResult(action="reject")}
|
|
)
|
|
_assert_pending_calls_only_in_last_message(messages)
|
|
# Nothing left to run: the last message is a tool result, not a pending tool call.
|
|
assert not messages[-1].tool_calls
|
|
|
|
def test_mixed_reject_and_confirm(self):
|
|
messages = self._run(
|
|
{"foo": ConfirmationUIResult(action="reject"), "bar": ConfirmationUIResult(action="confirm")}
|
|
)
|
|
_assert_pending_calls_only_in_last_message(messages)
|
|
# The rejected call is resolved earlier; only the confirmed survivor remains pending, on the last message.
|
|
assert [tc.tool_name for tc in messages[-1].tool_calls] == ["bar"]
|
|
|
|
def test_mixed_reject_and_modify(self):
|
|
messages = self._run(
|
|
{
|
|
"foo": ConfirmationUIResult(action="reject"),
|
|
"bar": ConfirmationUIResult(action="modify", new_tool_params={"x": 99}),
|
|
}
|
|
)
|
|
_assert_pending_calls_only_in_last_message(messages)
|
|
assert [tc.tool_name for tc in messages[-1].tool_calls] == ["bar"]
|
|
assert messages[-1].tool_calls[0].arguments == {"x": 99}
|