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
@@ -0,0 +1,58 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from haystack.human_in_the_loop import ConfirmationUIResult, ToolExecutionDecision
class TestConfirmationUIResult:
def test_init(self):
original = ConfirmationUIResult(action="reject", feedback="Changed my mind")
assert original.action == "reject"
assert original.feedback == "Changed my mind"
assert original.new_tool_params is None
class TestToolExecutionDecision:
def test_init(self):
decision = ToolExecutionDecision(
execute=True,
tool_name="test_tool",
tool_call_id="test_tool_call_id",
final_tool_params={"param1": "new_value"},
)
assert decision.execute is True
assert decision.final_tool_params == {"param1": "new_value"}
assert decision.tool_call_id == "test_tool_call_id"
assert decision.tool_name == "test_tool"
def test_to_dict(self):
original = ToolExecutionDecision(
execute=True,
tool_name="test_tool",
tool_call_id="test_tool_call_id",
final_tool_params={"param1": "new_value"},
)
as_dict = original.to_dict()
assert as_dict == {
"execute": True,
"tool_name": "test_tool",
"tool_call_id": "test_tool_call_id",
"feedback": None,
"final_tool_params": {"param1": "new_value"},
}
def test_from_dict(self):
data = {
"execute": False,
"tool_name": "another_tool",
"tool_call_id": "another_tool_call_id",
"feedback": "Not needed",
"final_tool_params": {"paramA": 123},
}
decision = ToolExecutionDecision.from_dict(data)
assert decision.execute is False
assert decision.tool_name == "another_tool"
assert decision.tool_call_id == "another_tool_call_id"
assert decision.feedback == "Not needed"
assert decision.final_tool_params == {"paramA": 123}
+358
View File
@@ -0,0 +1,358 @@
# 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}
+92
View File
@@ -0,0 +1,92 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.human_in_the_loop import AlwaysAskPolicy, AskOncePolicy, ConfirmationUIResult, NeverAskPolicy
from haystack.tools import Tool, create_tool_from_function
def addition(x: int, y: int) -> int:
return x + y
@pytest.fixture
def addition_tool() -> Tool:
return create_tool_from_function(function=addition, name="Addition tool", description="Adds two integers together.")
class TestAlwaysAskPolicy:
def test_should_ask_always_true(self, addition_tool):
policy = AlwaysAskPolicy()
assert policy.should_ask(addition_tool.name, addition_tool.description, {"x": 1, "y": 2}) is True
def test_to_dict(self):
policy = AlwaysAskPolicy()
policy_dict = policy.to_dict()
assert policy_dict["type"] == "haystack.human_in_the_loop.policies.AlwaysAskPolicy"
assert policy_dict["init_parameters"] == {}
def test_from_dict(self):
policy_dict = {"type": "haystack.human_in_the_loop.policies.AlwaysAskPolicy", "init_parameters": {}}
policy = AlwaysAskPolicy.from_dict(policy_dict)
assert isinstance(policy, AlwaysAskPolicy)
class TestAskOncePolicy:
def test_should_ask_first_time_true(self, addition_tool):
policy = AskOncePolicy()
assert policy.should_ask(addition_tool.name, addition_tool.description, {"x": 1, "y": 2}) is True
def test_should_ask_second_time_false(self, addition_tool):
policy = AskOncePolicy()
params = {"x": 1, "y": 2}
assert policy.should_ask(addition_tool.name, addition_tool.description, params) is True
# Simulate the update after confirmation that occurs in HumanInTheLoopStrategy
policy.update_after_confirmation(
addition_tool.name, addition_tool.description, params, ConfirmationUIResult(action="confirm", feedback=None)
)
assert policy.should_ask(addition_tool.name, addition_tool.description, params) is False
def test_should_ask_different_params_true(self, addition_tool):
policy = AskOncePolicy()
params1 = {"x": 1, "y": 2}
params2 = {"x": 3, "y": 4}
assert policy.should_ask(addition_tool.name, addition_tool.description, params1) is True
# Simulate the update after confirmation that occurs in HumanInTheLoopStrategy
policy.update_after_confirmation(
addition_tool.name,
addition_tool.description,
params1,
ConfirmationUIResult(action="confirm", feedback=None),
)
assert policy.should_ask(addition_tool.name, addition_tool.description, params2) is True
def test_to_dict(self):
policy = AskOncePolicy()
policy_dict = policy.to_dict()
assert policy_dict["type"] == "haystack.human_in_the_loop.policies.AskOncePolicy"
assert policy_dict["init_parameters"] == {}
def test_from_dict(self):
policy_dict = {"type": "haystack.human_in_the_loop.policies.AskOncePolicy", "init_parameters": {}}
policy = AskOncePolicy.from_dict(policy_dict)
assert isinstance(policy, AskOncePolicy)
class TestNeverAskPolicy:
def test_should_ask_always_false(self, addition_tool):
policy = NeverAskPolicy()
assert policy.should_ask(addition_tool.name, addition_tool.description, {"x": 1, "y": 2}) is False
def test_to_dict(self):
policy = NeverAskPolicy()
policy_dict = policy.to_dict()
assert policy_dict["type"] == "haystack.human_in_the_loop.policies.NeverAskPolicy"
assert policy_dict["init_parameters"] == {}
def test_from_dict(self):
policy_dict = {"type": "haystack.human_in_the_loop.policies.NeverAskPolicy", "init_parameters": {}}
policy = NeverAskPolicy.from_dict(policy_dict)
assert isinstance(policy, NeverAskPolicy)
+745
View File
@@ -0,0 +1,745 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
from typing import Any
import pytest
from haystack.components.agents.state.state import State
from haystack.components.agents.tool_calling import ToolNotFoundException, _run_tool
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.human_in_the_loop import (
AlwaysAskPolicy,
AskOncePolicy,
BlockingConfirmationStrategy,
ConfirmationUIResult,
NeverAskPolicy,
SimpleConsoleUI,
ToolExecutionDecision,
)
from haystack.human_in_the_loop.strategies import (
_apply_tool_execution_decisions,
_process_confirmation_strategies,
_run_confirmation_strategies,
_run_confirmation_strategies_async,
_update_chat_history,
)
from haystack.tools import Tool, create_tool_from_function
def addition_tool(a: int, b: int) -> int:
return a + b
def multiplication_tool(a: int, b: int) -> int:
return a * b
@pytest.fixture
def tools() -> list[Tool]:
add_tool = create_tool_from_function(
function=addition_tool, name="addition_tool", description="A tool that adds two integers together."
)
mult_tool = create_tool_from_function(
function=multiplication_tool, name="multiplication_tool", description="A tool that multiplies two integers."
)
return [add_tool, mult_tool]
class TestBlockingConfirmationStrategy:
def test_initialization(self):
strategy = BlockingConfirmationStrategy(confirmation_policy=AskOncePolicy(), confirmation_ui=SimpleConsoleUI())
assert isinstance(strategy.confirmation_policy, AskOncePolicy)
assert isinstance(strategy.confirmation_ui, SimpleConsoleUI)
def test_to_dict(self):
strategy = BlockingConfirmationStrategy(confirmation_policy=AskOncePolicy(), confirmation_ui=SimpleConsoleUI())
strategy_dict = strategy.to_dict()
assert strategy_dict == {
"type": "haystack.human_in_the_loop.strategies.BlockingConfirmationStrategy",
"init_parameters": {
"confirmation_policy": {
"type": "haystack.human_in_the_loop.policies.AskOncePolicy",
"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(self):
strategy_dict = {
"type": "haystack.human_in_the_loop.strategies.BlockingConfirmationStrategy",
"init_parameters": {
"confirmation_policy": {
"type": "haystack.human_in_the_loop.policies.AskOncePolicy",
"init_parameters": {},
},
"confirmation_ui": {
"type": "haystack.human_in_the_loop.user_interfaces.SimpleConsoleUI",
"init_parameters": {},
},
},
}
strategy = BlockingConfirmationStrategy.from_dict(strategy_dict)
assert isinstance(strategy, BlockingConfirmationStrategy)
assert isinstance(strategy.confirmation_policy, AskOncePolicy)
assert isinstance(strategy.confirmation_ui, SimpleConsoleUI)
def test_run_confirm(self, monkeypatch):
strategy = BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
# Mock the UI to always confirm
def mock_get_user_confirmation(tool_name, tool_description, tool_params):
return ConfirmationUIResult(action="confirm")
monkeypatch.setattr(strategy.confirmation_ui, "get_user_confirmation", mock_get_user_confirmation)
decision = strategy.run(tool_name="test_tool", tool_description="A test tool", tool_params={"param1": "value1"})
assert decision.tool_name == "test_tool"
assert decision.execute is True
assert decision.final_tool_params == {"param1": "value1"}
def test_run_modify(self, monkeypatch):
strategy = BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
# Mock the UI to always modify
def mock_get_user_confirmation(tool_name, tool_description, tool_params):
return ConfirmationUIResult(action="modify", new_tool_params={"param1": "new_value"})
monkeypatch.setattr(strategy.confirmation_ui, "get_user_confirmation", mock_get_user_confirmation)
decision = strategy.run(tool_name="test_tool", tool_description="A test tool", tool_params={"param1": "value1"})
assert decision.tool_name == "test_tool"
assert decision.execute is True
assert decision.final_tool_params == {"param1": "new_value"}
assert decision.feedback == (
"The parameters for tool 'test_tool' were updated by the user to:\n{'param1': 'new_value'}"
)
def test_run_reject(self, monkeypatch):
strategy = BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
# Mock the UI to always reject
def mock_get_user_confirmation(tool_name, tool_description, tool_params):
return ConfirmationUIResult(action="reject", feedback="Not needed")
monkeypatch.setattr(strategy.confirmation_ui, "get_user_confirmation", mock_get_user_confirmation)
decision = strategy.run(tool_name="test_tool", tool_description="A test tool", tool_params={"param1": "value1"})
assert decision.tool_name == "test_tool"
assert decision.execute is False
assert decision.final_tool_params is None
assert decision.feedback == (
"Tool execution for 'test_tool' was rejected by the user. With user feedback: Not needed"
)
class TestRunConfirmationStrategies:
def test_run_confirmation_strategies_no_strategy(self, tools):
teds = _run_confirmation_strategies(
confirmation_strategies={},
messages_with_tool_calls=[
ChatMessage.from_assistant(tool_calls=[ToolCall(tools[0].name, {"param1": "value1"})])
],
tools=tools,
)
assert teds == [
ToolExecutionDecision(tool_name=tools[0].name, execute=True, final_tool_params={"param1": "value1"})
]
def test_run_confirmation_strategies_with_strategy(self, tools):
teds = _run_confirmation_strategies(
confirmation_strategies={
tools[0].name: BlockingConfirmationStrategy(
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
},
messages_with_tool_calls=[
ChatMessage.from_assistant(tool_calls=[ToolCall(tools[0].name, {"param1": "value1"})])
],
tools=tools,
)
assert teds == [
ToolExecutionDecision(tool_name=tools[0].name, execute=True, final_tool_params={"param1": "value1"})
]
def test_run_confirmation_strategies_with_multi_tool_tuple_key(self, tools):
add_tool, mult_tool = tools[0], tools[1]
strategy = BlockingConfirmationStrategy(confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI())
teds = _run_confirmation_strategies(
confirmation_strategies={(add_tool.name, mult_tool.name): strategy},
messages_with_tool_calls=[
ChatMessage.from_assistant(
tool_calls=[ToolCall(add_tool.name, {"a": 1, "b": 2}), ToolCall(mult_tool.name, {"a": 3, "b": 4})]
)
],
tools=tools,
)
assert len(teds) == 2
assert teds[0].tool_name == add_tool.name
assert teds[0].execute is True
assert teds[1].tool_name == mult_tool.name
assert teds[1].execute is True
def test_run_confirmation_strategies_unknown_tool_passes_through(self, tools):
# The model hallucinated a tool name that isn't in the available tools. Confirmation is skipped and the
# call passes through unchanged so the tool-calling code can report it (ToolNotFoundException).
teds = _run_confirmation_strategies(
confirmation_strategies={
tools[0].name: BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
},
messages_with_tool_calls=[
ChatMessage.from_assistant(
tool_calls=[ToolCall(id="tc-1", tool_name="hallucinated_tool", arguments={"a": 1})]
)
],
tools=tools,
)
assert teds == [
ToolExecutionDecision(
tool_call_id="tc-1", tool_name="hallucinated_tool", execute=True, final_tool_params={"a": 1}
)
]
class TestUnknownToolEndToEnd:
def test_hallucinated_tool_survives_confirmation_and_is_reported_as_not_found(self, tools):
# A confirmation strategy is configured, and the model emits a tool call for a tool that doesn't exist.
# The hallucinated call must pass through confirmation unchanged and then be reported by the tool-calling
# code as not found, exactly as it would be without any confirmation strategy configured.
state = State(schema={"messages": {"type": list[ChatMessage]}})
tool_call = ToolCall(id="tc-1", tool_name="hallucinated_tool", arguments={"a": 1})
assistant_message = ChatMessage.from_assistant(tool_calls=[tool_call])
state.set("messages", [ChatMessage.from_user("do something"), assistant_message])
new_chat_history = _process_confirmation_strategies(
confirmation_strategies={
tools[0].name: BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
},
messages_with_tool_calls=[assistant_message],
tools=tools,
state=state,
)
# The hallucinated call survives the confirmation layer unchanged (it is not dropped), as the pending call
# on the last message of the returned history.
pending_messages = [new_chat_history[-1]]
surviving_calls = [tc for message in pending_messages for tc in (message.tool_calls or [])]
assert surviving_calls == [tool_call]
# raise_on_failure=True: the tool-calling code raises ToolNotFoundException.
with pytest.raises(ToolNotFoundException):
_run_tool(messages=pending_messages, state=state, tools=tools)
# raise_on_failure=False: it returns an error tool message instead of crashing.
tool_messages, _ = _run_tool(messages=pending_messages, state=state, tools=tools, raise_on_failure=False)
assert tool_messages[0].tool_call_results[0].error
assert "not found" in tool_messages[0].tool_call_results[0].result
class TestApplyToolExecutionDecisions:
@pytest.fixture
def assistant_message(self, tools):
tool_call = ToolCall(tool_name=tools[0].name, arguments={"a": 1, "b": 2}, id="1")
return ChatMessage.from_assistant(tool_calls=[tool_call])
def test_reject(self, tools, assistant_message):
rejection_messages, new_tool_call_messages = _apply_tool_execution_decisions(
tool_call_messages=[assistant_message],
tool_execution_decisions=[
ToolExecutionDecision(
tool_name=tools[0].name,
execute=False,
tool_call_id="1",
feedback=(
"The tool execution for 'addition_tool' was rejected by the user. With feedback: Not needed"
),
)
],
)
assert rejection_messages == [
assistant_message,
ChatMessage.from_tool(
tool_result=(
"The tool execution for 'addition_tool' was rejected by the user. With feedback: Not needed"
),
origin=assistant_message.tool_call,
error=True,
),
]
assert new_tool_call_messages == []
def test_confirm(self, tools, assistant_message):
rejection_messages, new_tool_call_messages = _apply_tool_execution_decisions(
tool_call_messages=[assistant_message],
tool_execution_decisions=[
ToolExecutionDecision(
tool_name=tools[0].name, execute=True, tool_call_id="1", final_tool_params={"a": 1, "b": 2}
)
],
)
assert rejection_messages == []
assert new_tool_call_messages == [assistant_message]
def test_modify(self, tools, assistant_message):
rejection_messages, new_tool_call_messages = _apply_tool_execution_decisions(
tool_call_messages=[assistant_message],
tool_execution_decisions=[
ToolExecutionDecision(
tool_name=tools[0].name,
execute=True,
tool_call_id="1",
final_tool_params={"a": 5, "b": 6},
feedback="The parameters for tool 'addition_tool' were updated by the user to:\n{'a': 5, 'b': 6}",
)
],
)
assert rejection_messages == []
assert new_tool_call_messages == [
ChatMessage.from_user(
"The parameters for tool 'addition_tool' were updated by the user to:\n{'a': 5, 'b': 6}"
),
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name=tools[0].name, arguments={"a": 5, "b": 6}, id="1")]
),
]
def test_two_teds_same_name_no_ids(self):
message_with_tool_calls = ChatMessage.from_assistant(
text="I'll extract the information about the people mentioned in the context.",
# Same tool name with different params but missing IDs
tool_calls=[
ToolCall(tool_name="add_database_tool", arguments={"name": "Malte"}),
ToolCall(tool_name="add_database_tool", arguments={"name": "Milos"}),
],
)
# This raises a ValueError because tool_call_id is missing and there are multiple tool calls with the same name
# so we cannot disambiguate which TED applies to which tool call.
with pytest.raises(
ValueError,
match="ToolExecutionDecisions are missing tool_call_id fields and there are multiple tool calls with the "
"same name",
):
_apply_tool_execution_decisions(
tool_call_messages=[message_with_tool_calls],
tool_execution_decisions=[
ToolExecutionDecision(
tool_name="add_database_tool", execute=True, final_tool_params={"name": "Malte"}
),
ToolExecutionDecision(
tool_name="add_database_tool", execute=True, final_tool_params={"name": "Milos"}
),
],
)
class TestUpdateChatHistory:
@pytest.fixture
def chat_history_one_tool_call(self):
return [
ChatMessage.from_user("Hello"),
ChatMessage.from_assistant(tool_calls=[ToolCall("tool1", {"a": 1, "b": 2}, id="1")]),
]
def test_update_chat_history_rejection(self, chat_history_one_tool_call):
"""Test that the new history includes a tool call result message after a rejection."""
rejection_messages = [
ChatMessage.from_assistant(tool_calls=[chat_history_one_tool_call[1].tool_call]),
ChatMessage.from_tool(
tool_result="The tool execution for 'tool1' was rejected by the user. With feedback: Not needed",
origin=chat_history_one_tool_call[1].tool_call,
error=True,
),
]
updated_messages = _update_chat_history(
chat_history_one_tool_call, rejection_messages=rejection_messages, tool_call_and_explanation_messages=[]
)
assert updated_messages == [chat_history_one_tool_call[0], *rejection_messages]
def test_update_chat_history_confirm(self, chat_history_one_tool_call):
"""No changes should be made if the tool call was confirmed."""
tool_call_messages = [ChatMessage.from_assistant(tool_calls=[chat_history_one_tool_call[1].tool_call])]
updated_messages = _update_chat_history(
chat_history_one_tool_call, rejection_messages=[], tool_call_and_explanation_messages=tool_call_messages
)
assert updated_messages == chat_history_one_tool_call
def test_update_chat_history_modify(self, chat_history_one_tool_call):
"""Test that the new history includes a user message and updated tool call after a modification."""
tool_call_messages = [
ChatMessage.from_user(
"The parameters for tool 'tool1' were updated by the user to:\n{'param': 'new_value'}"
),
ChatMessage.from_assistant(tool_calls=[ToolCall("tool1", {"param": "new_value"}, id="1")]),
]
updated_messages = _update_chat_history(
chat_history_one_tool_call, rejection_messages=[], tool_call_and_explanation_messages=tool_call_messages
)
assert updated_messages == [chat_history_one_tool_call[0], *tool_call_messages]
def test_update_chat_history_modify_two_tool_calls(self):
tool_call_message = ChatMessage.from_assistant(
tool_calls=[ToolCall("tool1", {"a": 1, "b": 2}, id="1"), ToolCall("tool2", {"a": 3, "b": 4}, id="2")]
)
chat_history = [ChatMessage.from_user("What is 1 + 2? and 3 + 4?"), tool_call_message]
rejection_messages, modified_tool_call_messages = _apply_tool_execution_decisions(
tool_call_messages=[tool_call_message],
tool_execution_decisions=[
ToolExecutionDecision(
tool_name="tool1",
execute=True,
tool_call_id="1",
final_tool_params={"a": 5, "b": 6},
feedback="The parameters for tool 'tool1' were updated by the user to:\n{'a': 5, 'b': 6}",
),
ToolExecutionDecision(
tool_name="tool2",
execute=True,
tool_call_id="2",
final_tool_params={"a": 7, "b": 8},
feedback="The parameters for tool 'tool2' were updated by the user to:\n{'a': 7, 'b': 8}'",
),
],
)
updated_messages = _update_chat_history(
chat_history,
rejection_messages=rejection_messages,
tool_call_and_explanation_messages=modified_tool_call_messages,
)
assert updated_messages == [
chat_history[0],
ChatMessage.from_user("The parameters for tool 'tool1' were updated by the user to:\n{'a': 5, 'b': 6}"),
ChatMessage.from_user("The parameters for tool 'tool2' were updated by the user to:\n{'a': 7, 'b': 8}'"),
ChatMessage.from_assistant(
tool_calls=[
ToolCall(tool_name="tool1", arguments={"a": 5, "b": 6}, id="1", extra=None),
ToolCall(tool_name="tool2", arguments={"a": 7, "b": 8}, id="2", extra=None),
]
),
]
def test_update_chat_history_two_tool_calls_modify_and_reject(self):
tool_call_message = ChatMessage.from_assistant(
tool_calls=[ToolCall("tool1", {"a": 1, "b": 2}, id="1"), ToolCall("tool2", {"a": 3, "b": 4}, id="2")]
)
chat_history = [ChatMessage.from_user("What is 1 + 2? and 3 + 4?"), tool_call_message]
rejection_messages, modified_tool_call_messages = _apply_tool_execution_decisions(
tool_call_messages=[tool_call_message],
tool_execution_decisions=[
ToolExecutionDecision(
tool_name="tool1",
execute=True,
tool_call_id="1",
final_tool_params={"a": 5, "b": 6},
feedback="The parameters for tool 'tool1' were updated by the user to:\n{'a': 5, 'b': 6}",
),
ToolExecutionDecision(
tool_name="tool2",
execute=False,
tool_call_id="2",
feedback="The tool execution for 'tool2' was rejected by the user. With feedback: Not needed",
),
],
)
updated_messages = _update_chat_history(
chat_history,
rejection_messages=rejection_messages,
tool_call_and_explanation_messages=modified_tool_call_messages,
)
assert updated_messages == [
chat_history[0],
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="tool2", arguments={"a": 3, "b": 4}, id="2", extra=None)]
),
ChatMessage.from_tool(
tool_result="The tool execution for 'tool2' was rejected by the user. With feedback: Not needed",
origin=ToolCall(tool_name="tool2", arguments={"a": 3, "b": 4}, id="2", extra=None),
error=True,
),
ChatMessage.from_user("The parameters for tool 'tool1' were updated by the user to:\n{'a': 5, 'b': 6}"),
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="tool1", arguments={"a": 5, "b": 6}, id="1", extra=None)]
),
]
class ConfirmationStrategyContextCapturingStrategy:
def __init__(self):
self.captured_confirmation_strategy_context: dict[str, Any] | None = None
def run(
self,
tool_name: str,
tool_description: str,
tool_params: dict[str, Any],
tool_call_id: str | None = None,
confirmation_strategy_context: dict[str, Any] | None = None,
) -> ToolExecutionDecision:
self.captured_confirmation_strategy_context = confirmation_strategy_context
return ToolExecutionDecision(
tool_name=tool_name, execute=True, tool_call_id=tool_call_id, final_tool_params=tool_params
)
async def run_async(
self,
tool_name: str,
tool_description: str,
tool_params: dict[str, Any],
tool_call_id: str | None = None,
confirmation_strategy_context: dict[str, Any] | None = None,
) -> ToolExecutionDecision:
self.captured_confirmation_strategy_context = confirmation_strategy_context
return ToolExecutionDecision(
tool_name=tool_name, execute=True, tool_call_id=tool_call_id, final_tool_params=tool_params
)
def to_dict(self) -> dict[str, Any]:
return {"type": "test.RunContextCapturingStrategy", "init_parameters": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConfirmationStrategyContextCapturingStrategy":
return cls()
class TestRunContext:
def test_confirmation_strategy_context_passed_to_strategy(self, tools):
confirmation_strategy_context = {"event_queue": "mock_queue", "redis_client": "mock_redis"}
capturing_strategy = ConfirmationStrategyContextCapturingStrategy()
teds = _run_confirmation_strategies(
confirmation_strategies={tools[0].name: capturing_strategy},
messages_with_tool_calls=[
ChatMessage.from_assistant(tool_calls=[ToolCall(tools[0].name, {"a": 1, "b": 2})])
],
tools=tools,
confirmation_strategy_context=confirmation_strategy_context,
)
# Verify the strategy received the confirmation_strategy_context directly
assert capturing_strategy.captured_confirmation_strategy_context is not None
assert capturing_strategy.captured_confirmation_strategy_context == confirmation_strategy_context
assert len(teds) == 1
assert teds[0].execute is True
@pytest.mark.asyncio
async def test_confirmation_strategy_context_passed_to_strategy_async(self, tools):
confirmation_strategy_context = {"websocket": "mock_websocket", "request_id": "12345"}
capturing_strategy = ConfirmationStrategyContextCapturingStrategy()
teds = await _run_confirmation_strategies_async(
confirmation_strategies={tools[0].name: capturing_strategy},
messages_with_tool_calls=[
ChatMessage.from_assistant(tool_calls=[ToolCall(tools[0].name, {"a": 1, "b": 2})])
],
tools=tools,
confirmation_strategy_context=confirmation_strategy_context,
)
# Verify the strategy received the confirmation_strategy_context directly
assert capturing_strategy.captured_confirmation_strategy_context is not None
assert capturing_strategy.captured_confirmation_strategy_context == confirmation_strategy_context
assert len(teds) == 1
assert teds[0].execute is True
class TrueAsyncConfirmationStrategy:
"""
A confirmation strategy with truly async behavior for testing purposes.
This strategy simulates an async operation (like waiting for a WebSocket response)
by using asyncio.sleep. It demonstrates how custom strategies can implement
non-blocking async confirmation flows.
"""
def __init__(self, delay: float = 0.01, decision: str = "confirm"):
self.delay = delay
self.decision = decision
self.async_was_called = False
self.sync_was_called = False
def run(
self,
tool_name: str,
tool_description: str,
tool_params: dict[str, Any],
tool_call_id: str | None = None,
confirmation_strategy_context: dict[str, Any] | None = None,
) -> ToolExecutionDecision:
"""Sync version - should NOT be called when run_async is available."""
self.sync_was_called = True
return ToolExecutionDecision(
tool_name=tool_name, execute=True, tool_call_id=tool_call_id, final_tool_params=tool_params
)
async def run_async(
self,
tool_name: str,
tool_description: str,
tool_params: dict[str, Any],
tool_call_id: str | None = None,
confirmation_strategy_context: dict[str, Any] | None = None,
) -> ToolExecutionDecision:
"""Truly async version that simulates waiting for external confirmation."""
self.async_was_called = True
# Simulate async operation (e.g., waiting for WebSocket response)
await asyncio.sleep(self.delay)
if self.decision == "reject":
return ToolExecutionDecision(
tool_name=tool_name,
execute=False,
tool_call_id=tool_call_id,
feedback=f"Tool '{tool_name}' was rejected asynchronously.",
)
return ToolExecutionDecision(
tool_name=tool_name, execute=True, tool_call_id=tool_call_id, final_tool_params=tool_params
)
def to_dict(self) -> dict[str, Any]:
return {"type": "test.TrueAsyncConfirmationStrategy", "init_parameters": {"delay": self.delay}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "TrueAsyncConfirmationStrategy":
return cls(**data.get("init_parameters", {}))
class TestAsyncConfirmationStrategies:
@pytest.mark.asyncio
async def test_async_strategy_confirm(self):
strategy = TrueAsyncConfirmationStrategy(delay=0.01, decision="confirm")
decision = await strategy.run_async(
tool_name="test_tool", tool_description="A test tool", tool_params={"param1": "value1"}
)
assert strategy.async_was_called is True
assert strategy.sync_was_called is False
assert decision.tool_name == "test_tool"
assert decision.execute is True
assert decision.final_tool_params == {"param1": "value1"}
@pytest.mark.asyncio
async def test_async_strategy_reject(self):
strategy = TrueAsyncConfirmationStrategy(delay=0.01, decision="reject")
decision = await strategy.run_async(
tool_name="test_tool", tool_description="A test tool", tool_params={"param1": "value1"}
)
assert strategy.async_was_called is True
assert decision.execute is False
assert decision.feedback is not None and "rejected asynchronously" in decision.feedback
@pytest.mark.asyncio
async def test_async_strategy_used_by_run_confirmation_strategies_async(self, tools):
strategy = TrueAsyncConfirmationStrategy(delay=0.01, decision="confirm")
teds = await _run_confirmation_strategies_async(
confirmation_strategies={tools[0].name: strategy},
messages_with_tool_calls=[
ChatMessage.from_assistant(tool_calls=[ToolCall(tools[0].name, {"a": 1, "b": 2})])
],
tools=tools,
)
# Verify that only the async method was called
assert strategy.async_was_called is True
assert strategy.sync_was_called is False
assert len(teds) == 1
assert teds[0].execute is True
@pytest.mark.asyncio
async def test_blocking_strategy_run_async(self, monkeypatch):
strategy = BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
# Mock the UI to always confirm
def mock_get_user_confirmation(tool_name, tool_description, tool_params):
return ConfirmationUIResult(action="confirm")
monkeypatch.setattr(strategy.confirmation_ui, "get_user_confirmation", mock_get_user_confirmation)
decision = await strategy.run_async(
tool_name="test_tool", tool_description="A test tool", tool_params={"param1": "value1"}
)
assert decision.tool_name == "test_tool"
assert decision.execute is True
assert decision.final_tool_params == {"param1": "value1"}
@pytest.mark.asyncio
async def test_run_confirmation_strategies_async_no_strategy(self, tools):
teds = await _run_confirmation_strategies_async(
confirmation_strategies={},
messages_with_tool_calls=[
ChatMessage.from_assistant(tool_calls=[ToolCall(tools[0].name, {"a": 1, "b": 2})])
],
tools=tools,
)
assert len(teds) == 1
assert teds[0].tool_name == tools[0].name
assert teds[0].execute is True
assert teds[0].final_tool_params == {"a": 1, "b": 2}
@pytest.mark.asyncio
async def test_run_confirmation_strategies_async_with_strategy(self, tools):
teds = await _run_confirmation_strategies_async(
confirmation_strategies={
tools[0].name: BlockingConfirmationStrategy(
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
},
messages_with_tool_calls=[
ChatMessage.from_assistant(tool_calls=[ToolCall(tools[0].name, {"a": 1, "b": 2})])
],
tools=tools,
)
assert len(teds) == 1
assert teds[0].tool_name == tools[0].name
assert teds[0].execute is True
@pytest.mark.asyncio
async def test_run_confirmation_strategies_async_unknown_tool_passes_through(self, tools):
# The model hallucinated a tool name that isn't in the available tools. Confirmation is skipped and the
# call passes through unchanged so the tool-calling code can report it (ToolNotFoundException).
teds = await _run_confirmation_strategies_async(
confirmation_strategies={
tools[0].name: BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
},
messages_with_tool_calls=[
ChatMessage.from_assistant(
tool_calls=[ToolCall(id="tc-1", tool_name="hallucinated_tool", arguments={"a": 1})]
)
],
tools=tools,
)
assert teds == [
ToolExecutionDecision(
tool_call_id="tc-1", tool_name="hallucinated_tool", execute=True, final_tool_params={"a": 1}
)
]
@@ -0,0 +1,202 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import MagicMock, patch
import pytest
from haystack.human_in_the_loop import ConfirmationUIResult, RichConsoleUI, SimpleConsoleUI
from haystack.tools import create_tool_from_function
def multiply_tool(x: int) -> int:
return x * 2
@pytest.fixture
def tool():
return create_tool_from_function(
function=multiply_tool, name="test_tool", description="A test tool that multiplies input by 2."
)
class TestRichConsoleUI:
@pytest.mark.parametrize("choice", ["y"])
def test_process_choice_confirm(self, tool, choice):
ui = RichConsoleUI(console=MagicMock())
with patch("haystack.human_in_the_loop.user_interfaces.Prompt.ask", side_effect=[choice, "feedback"]):
result = ui.get_user_confirmation(tool.name, tool.description, {"x": 1})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "confirm"
assert result.new_tool_params is None
assert result.feedback is None
@pytest.mark.parametrize("choice", ["m"])
def test_process_choice_modify(self, tool, choice):
ui = RichConsoleUI(console=MagicMock())
with patch("haystack.human_in_the_loop.user_interfaces.Prompt.ask", side_effect=["m", "2"]):
result = ui.get_user_confirmation(tool.name, tool.description, {"x": 1})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "modify"
assert result.new_tool_params == {"x": 2}
def test_process_choice_modify_dict_param(self, tool):
ui = RichConsoleUI(console=MagicMock())
with patch("haystack.human_in_the_loop.user_interfaces.Prompt.ask", side_effect=["m", '{"key": "value"}']):
result = ui.get_user_confirmation(tool.name, tool.description, {"param1": {"old_key": "old_value"}})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "modify"
assert result.new_tool_params == {"param1": {"key": "value"}}
def test_process_choice_modify_dict_param_invalid_json(self, tool):
ui = RichConsoleUI(console=MagicMock())
with patch(
"haystack.human_in_the_loop.user_interfaces.Prompt.ask",
side_effect=["m", "invalid_json", '{"key": "value"}'],
):
result = ui.get_user_confirmation(tool.name, tool.description, {"param1": {"old_key": "old_value"}})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "modify"
assert result.new_tool_params == {"param1": {"key": "value"}}
@pytest.mark.parametrize("choice", ["n"])
def test_process_choice_reject(self, tool, choice):
ui = RichConsoleUI(console=MagicMock())
with patch("haystack.human_in_the_loop.user_interfaces.Prompt.ask", side_effect=["n", "Changed my mind"]):
result = ui.get_user_confirmation(tool.name, tool.description, {"x": 1})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "reject"
assert result.feedback == "Changed my mind"
def test_to_dict(self):
ui = RichConsoleUI()
data = ui.to_dict()
assert data["type"] == ("haystack.human_in_the_loop.user_interfaces.RichConsoleUI")
assert data["init_parameters"]["console"] is None
def test_from_dict(self):
ui = RichConsoleUI()
data = ui.to_dict()
new_ui = RichConsoleUI.from_dict(data)
assert isinstance(new_ui, RichConsoleUI)
class TestSimpleConsoleUI:
@pytest.mark.parametrize("choice", ["y", "yes", "Y", "YES"])
def test_process_choice_confirm(self, tool, choice):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=[choice]):
result = ui.get_user_confirmation(tool.name, tool.description, {"y": "abc"})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "confirm"
@pytest.mark.parametrize("choice", ["m", "modify", "M", "MODIFY"])
def test_process_choice_modify(self, tool, choice):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=[choice, "new_value"]):
result = ui.get_user_confirmation(tool.name, tool.description, {"y": "abc"})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "modify"
assert result.new_tool_params == {"y": "new_value"}
def test_process_choice_modify_dict_param(self, tool):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=["m", '{"key": "value"}']):
result = ui.get_user_confirmation(tool.name, tool.description, {"param1": {"old_key": "old_value"}})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "modify"
assert result.new_tool_params == {"param1": {"key": "value"}}
def test_process_choice_modify_dict_param_invalid_json(self, tool):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=["m", "invalid_json", '{"key": "value"}']):
result = ui.get_user_confirmation(tool.name, tool.description, {"param1": {"old_key": "old_value"}})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "modify"
assert result.new_tool_params == {"param1": {"key": "value"}}
@pytest.mark.parametrize("choice", ["n", "no", "N", "NO"])
def test_process_choice_reject(self, tool, choice):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=[choice, "Changed my mind"]):
result = ui.get_user_confirmation(tool.name, tool.description, {"param1": "value1"})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "reject"
assert result.feedback == "Changed my mind"
def test_process_choice_no_tool_params_confirm(self, tool):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=["y"]):
result = ui.get_user_confirmation(tool.name, tool.description, {})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "confirm"
assert result.new_tool_params is None
assert result.feedback is None
def test_process_choice_no_tool_params_modify(self, tool):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=["m"]):
result = ui.get_user_confirmation(tool.name, tool.description, {})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "modify"
assert result.new_tool_params == {}
assert result.feedback is None
def test_process_choice_no_tool_params_reject(self, tool):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=["n", "Changed my mind"]):
result = ui.get_user_confirmation(tool.name, tool.description, {})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "reject"
assert result.new_tool_params is None
assert result.feedback == "Changed my mind"
def test_to_dict(self):
ui = SimpleConsoleUI()
data = ui.to_dict()
assert data["type"] == ("haystack.human_in_the_loop.user_interfaces.SimpleConsoleUI")
assert data["init_parameters"] == {}
def test_from_dict(self):
ui = SimpleConsoleUI()
data = ui.to_dict()
new_ui = SimpleConsoleUI.from_dict(data)
assert isinstance(new_ui, SimpleConsoleUI)
def test_get_user_confirmation_invalid_input_then_valid(self, tool):
ui = SimpleConsoleUI()
with patch("builtins.input", side_effect=["invalid", "y"]):
result = ui.get_user_confirmation(tool.name, tool.description, {"x": 1})
assert isinstance(result, ConfirmationUIResult)
assert result.action == "confirm"
assert result.new_tool_params is None
assert result.feedback is None