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
+30
View File
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import sys
from typing import TYPE_CHECKING
from lazy_imports import LazyImporter
_import_structure = {
"dataclasses": ["ConfirmationUIResult", "ToolExecutionDecision"],
"hooks": ["ConfirmationHook"],
"policies": ["AlwaysAskPolicy", "AskOncePolicy", "NeverAskPolicy"],
"strategies": ["BlockingConfirmationStrategy"],
"user_interfaces": ["RichConsoleUI", "SimpleConsoleUI"],
}
if TYPE_CHECKING:
from .dataclasses import ConfirmationUIResult as ConfirmationUIResult
from .dataclasses import ToolExecutionDecision as ToolExecutionDecision
from .hooks import ConfirmationHook as ConfirmationHook
from .policies import AlwaysAskPolicy as AlwaysAskPolicy
from .policies import AskOncePolicy as AskOncePolicy
from .policies import NeverAskPolicy as NeverAskPolicy
from .strategies import BlockingConfirmationStrategy as BlockingConfirmationStrategy
from .user_interfaces import RichConsoleUI as RichConsoleUI
from .user_interfaces import SimpleConsoleUI as SimpleConsoleUI
else:
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
+72
View File
@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from dataclasses import asdict, dataclass
from typing import Any
@dataclass
class ConfirmationUIResult:
"""
Result of the confirmation UI interaction.
:param action:
The action taken by the user such as "confirm", "reject", or "modify".
This action type is not enforced to allow for custom actions to be implemented.
:param feedback:
Optional feedback message from the user. For example, if the user rejects the tool execution,
they might provide a reason for the rejection.
:param new_tool_params:
Optional set of new parameters for the tool. For example, if the user chooses to modify the tool parameters,
they can provide a new set of parameters here.
"""
action: str # "confirm", "reject", "modify"
feedback: str | None = None
new_tool_params: dict[str, Any] | None = None
@dataclass
class ToolExecutionDecision:
"""
Decision made regarding tool execution.
:param tool_name:
The name of the tool to be executed.
:param execute:
A boolean indicating whether to execute the tool with the provided parameters.
:param tool_call_id:
Optional unique identifier for the tool call. This can be used to track and correlate the decision with a
specific tool invocation.
:param feedback:
Optional feedback message.
For example, if the tool execution is rejected, this can contain the reason. Or if the tool parameters were
modified, this can contain the modification details.
:param final_tool_params:
Optional final parameters for the tool if execution is confirmed or modified.
"""
tool_name: str
execute: bool
tool_call_id: str | None = None
feedback: str | None = None
final_tool_params: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
"""
Convert the ToolExecutionDecision to a dictionary representation.
:return: A dictionary containing the tool execution decision details.
"""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ToolExecutionDecision":
"""
Populate the ToolExecutionDecision from a dictionary representation.
:param data: A dictionary containing the tool execution decision details.
:return: An instance of ToolExecutionDecision.
"""
return cls(**data)
+134
View File
@@ -0,0 +1,134 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from haystack.components.agents.state.state import State
from haystack.components.agents.state.state_utils import replace_values
from haystack.core.serialization import default_from_dict, default_to_dict
from haystack.human_in_the_loop.strategies import (
_deserialize_confirmation_strategies,
_process_confirmation_strategies,
_process_confirmation_strategies_async,
_serialize_confirmation_strategies,
)
from haystack.human_in_the_loop.types import ConfirmationStrategy
class ConfirmationHook:
"""
A `before_tool` Agent hook that applies Human-in-the-Loop confirmation strategies to pending tool calls.
Register it on an `Agent` to confirm, modify, or reject tool calls before they run:
```python
from haystack.components.agents import Agent
from haystack.human_in_the_loop import (
AlwaysAskPolicy,
BlockingConfirmationStrategy,
ConfirmationHook,
NeverAskPolicy,
RichConsoleUI,
SimpleConsoleUI,
)
hook = ConfirmationHook(
confirmation_strategies={
"my_tool": BlockingConfirmationStrategy(
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
)
}
)
agent = Agent(chat_generator=..., tools=[...], hooks={"before_tool": [hook]})
```
A key may be a single tool name, a tuple of tool names sharing one strategy, or the wildcard `"*"` which applies
to any tool without a more specific entry. More specific keys win, so you can set a default for all tools and
override individual ones:
```python
hook = ConfirmationHook(
confirmation_strategies={
"delete_file": BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=RichConsoleUI()
),
"*": BlockingConfirmationStrategy(
confirmation_policy=NeverAskPolicy(), confirmation_ui=SimpleConsoleUI()
),
}
)
```
Request-scoped resources for the strategies (e.g. a WebSocket or queue) are passed per run via the Agent's
`hook_context` argument (`agent.run(messages=[...], hook_context={...})`) and read by the hook with
`state.data.get("hook_context")`.
This hook only makes sense at the `before_tool` hook point, where the pending tool calls exist (between the model
requesting tools and those tools running); the Agent enforces this and raises if it is registered elsewhere. Use a
single ConfirmationHook with one entry per tool (or per tuple of tools) in `confirmation_strategies` rather than
registering several hooks.
"""
# Restrict this hook to the "before_tool" point; the Agent validates this at construction.
allowed_hook_points = ("before_tool",)
def __init__(self, confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy]) -> None:
"""
Initialize the hook with its per-tool confirmation strategies.
:param confirmation_strategies: Mapping of tool name (or a tuple of tool names) to its `ConfirmationStrategy`.
The wildcard key `"*"` applies to any tool without a more specific entry.
"""
self.confirmation_strategies = confirmation_strategies
def run(self, state: State) -> None:
"""
Confirm the pending tool calls, rewriting the `messages` in `state` to reflect modifications and rejections.
:param state: The Agent's live `State`. Reads the available tools (`state.data.get("tools")`) and the per-run
context (`state.data.get("hook_context")`), and the pending tool calls from the last message; writes the
updated conversation back to `messages`. Reads go through `state.data` rather than `state.get`, which
deep-copies and would break non-copyable resources (e.g. a WebSocket or client) in `hook_context`.
"""
messages = state.data.get("messages") or []
if not messages or not messages[-1].tool_calls:
return
new_chat_history = _process_confirmation_strategies(
confirmation_strategies=self.confirmation_strategies,
messages_with_tool_calls=[messages[-1]],
tools=state.data.get("tools") or [],
state=state,
confirmation_strategy_context=state.data.get("hook_context"),
)
state.set("messages", new_chat_history, handler_override=replace_values)
async def run_async(self, state: State) -> None:
"""Async version of `run`."""
messages = state.data.get("messages") or []
if not messages or not messages[-1].tool_calls:
return
new_chat_history = await _process_confirmation_strategies_async(
confirmation_strategies=self.confirmation_strategies,
messages_with_tool_calls=[messages[-1]],
tools=state.data.get("tools") or [],
state=state,
confirmation_strategy_context=state.data.get("hook_context"),
)
state.set("messages", new_chat_history, handler_override=replace_values)
def to_dict(self) -> dict[str, Any]:
"""Serialize the hook, including its confirmation strategies (tuple keys become JSON-array strings)."""
return default_to_dict(
self, confirmation_strategies=_serialize_confirmation_strategies(self.confirmation_strategies)
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConfirmationHook":
"""Deserialize the hook, reconstructing its confirmation strategies."""
init_params = data.get("init_parameters", {})
if init_params.get("confirmation_strategies") is not None:
init_params["confirmation_strategies"] = _deserialize_confirmation_strategies(
init_params["confirmation_strategies"]
)
return default_from_dict(cls, data)
+79
View File
@@ -0,0 +1,79 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from haystack.human_in_the_loop import ConfirmationUIResult
from haystack.human_in_the_loop.types import ConfirmationPolicy
class AlwaysAskPolicy(ConfirmationPolicy):
"""Always ask for confirmation."""
def should_ask(self, tool_name: str, tool_description: str, tool_params: dict[str, Any]) -> bool: # noqa: ARG002
"""
Always ask for confirmation before executing the tool.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
:returns: Always returns True, indicating confirmation is needed.
"""
return True
class NeverAskPolicy(ConfirmationPolicy):
"""Never ask for confirmation."""
def should_ask(self, tool_name: str, tool_description: str, tool_params: dict[str, Any]) -> bool: # noqa: ARG002
"""
Never ask for confirmation, always proceed with tool execution.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
:returns: Always returns False, indicating no confirmation is needed.
"""
return False
class AskOncePolicy(ConfirmationPolicy):
"""Ask only once per tool with specific parameters."""
def __init__(self) -> None:
"""Creates an instance of AskOncePolicy."""
self._asked_tools: dict[str, Any] = {}
def should_ask(self, tool_name: str, tool_description: str, tool_params: dict[str, Any]) -> bool: # noqa: ARG002
"""
Ask for confirmation only once per tool with specific parameters.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
:returns: True if confirmation is needed, False if already asked with the same parameters.
"""
# Don't ask again if we've already asked for this tool with the same parameters
return not (tool_name in self._asked_tools and self._asked_tools[tool_name] == tool_params)
def update_after_confirmation(
self,
tool_name: str,
tool_description: str, # noqa: ARG002
tool_params: dict[str, Any],
confirmation_result: ConfirmationUIResult,
) -> None:
"""
Store the tool and parameters if the action was "confirm" to avoid asking again.
This method updates the internal state to remember that the user has already confirmed the execution of the
tool with the given parameters.
:param tool_name: The name of the tool that was executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters that were passed to the tool.
:param confirmation_result: The result from the confirmation UI.
"""
if confirmation_result.action == "confirm":
self._asked_tools[tool_name] = tool_params
+624
View File
@@ -0,0 +1,624 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
from dataclasses import replace
from typing import Any
from haystack.components.agents.state.state import State
from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.human_in_the_loop import ToolExecutionDecision
from haystack.human_in_the_loop.types import ConfirmationPolicy, ConfirmationStrategy, ConfirmationUI
from haystack.tools import Tool
from haystack.utils.deserialization import deserialize_component_inplace
REJECTION_FEEDBACK_TEMPLATE = "Tool execution for '{tool_name}' was rejected by the user."
MODIFICATION_FEEDBACK_TEMPLATE = (
"The parameters for tool '{tool_name}' were updated by the user to:\n{final_tool_params}"
)
USER_FEEDBACK_TEMPLATE = "With user feedback: {feedback}"
class BlockingConfirmationStrategy:
"""
Confirmation strategy that blocks execution to gather user feedback.
"""
def __init__(
self,
*,
confirmation_policy: ConfirmationPolicy,
confirmation_ui: ConfirmationUI,
reject_template: str = REJECTION_FEEDBACK_TEMPLATE,
modify_template: str = MODIFICATION_FEEDBACK_TEMPLATE,
user_feedback_template: str = USER_FEEDBACK_TEMPLATE,
) -> None:
"""
Initialize the BlockingConfirmationStrategy with a confirmation policy and UI.
:param confirmation_policy:
The confirmation policy to determine when to ask for user confirmation.
:param confirmation_ui:
The user interface to interact with the user for confirmation.
:param reject_template:
Template for rejection feedback messages. It should include a `{tool_name}` placeholder.
:param modify_template:
Template for modification feedback messages. It should include `{tool_name}` and `{final_tool_params}`
placeholders.
:param user_feedback_template:
Template for user feedback messages. It should include a `{feedback}` placeholder.
"""
self.confirmation_policy = confirmation_policy
self.confirmation_ui = confirmation_ui
self.reject_template = reject_template
self.modify_template = modify_template
self.user_feedback_template = user_feedback_template
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, # noqa: ARG002
) -> ToolExecutionDecision:
"""
Run the human-in-the-loop strategy for a given tool and its parameters.
:param tool_name:
The name of the tool to be executed.
:param tool_description:
The description of the tool.
:param tool_params:
The parameters to be passed to the tool.
:param tool_call_id:
Optional unique identifier for the tool call. This can be used to track and correlate the decision with a
specific tool invocation.
:param confirmation_strategy_context:
Optional dictionary for passing request-scoped resources. Useful in web/server environments
to provide per-request objects (e.g., WebSocket connections, async queues, Redis pub/sub clients)
that strategies can use for non-blocking user interaction.
:returns:
A ToolExecutionDecision indicating whether to execute the tool with the given parameters, or a
feedback message if rejected.
"""
# Check if we should ask based on policy
if not self.confirmation_policy.should_ask(
tool_name=tool_name, tool_description=tool_description, tool_params=tool_params
):
return ToolExecutionDecision(
tool_name=tool_name, execute=True, tool_call_id=tool_call_id, final_tool_params=tool_params
)
# Get user confirmation through UI
confirmation_ui_result = self.confirmation_ui.get_user_confirmation(tool_name, tool_description, tool_params)
# Pass back the result to the policy for any learning/updating
self.confirmation_policy.update_after_confirmation(
tool_name, tool_description, tool_params, confirmation_ui_result
)
# Process the confirmation result
final_args = {}
if confirmation_ui_result.action == "reject":
explanation_text = self.reject_template.format(tool_name=tool_name)
if confirmation_ui_result.feedback:
explanation_text += " "
explanation_text += self.user_feedback_template.format(feedback=confirmation_ui_result.feedback)
return ToolExecutionDecision(
tool_name=tool_name, execute=False, tool_call_id=tool_call_id, feedback=explanation_text
)
if confirmation_ui_result.action == "modify" and confirmation_ui_result.new_tool_params:
# Update the tool call params with the new params
final_args.update(confirmation_ui_result.new_tool_params)
explanation_text = self.modify_template.format(tool_name=tool_name, final_tool_params=final_args)
if confirmation_ui_result.feedback:
explanation_text += " "
explanation_text += self.user_feedback_template.format(feedback=confirmation_ui_result.feedback)
return ToolExecutionDecision(
tool_name=tool_name,
tool_call_id=tool_call_id,
execute=True,
feedback=explanation_text,
final_tool_params=final_args,
)
# action == "confirm"
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:
"""
Async version of run. Calls the sync run() method by default.
:param tool_name:
The name of the tool to be executed.
:param tool_description:
The description of the tool.
:param tool_params:
The parameters to be passed to the tool.
:param tool_call_id:
Optional unique identifier for the tool call.
:param confirmation_strategy_context:
Optional dictionary for passing request-scoped resources.
:returns:
A ToolExecutionDecision indicating whether to execute the tool with the given parameters.
"""
return self.run(
tool_name=tool_name,
tool_description=tool_description,
tool_params=tool_params,
tool_call_id=tool_call_id,
confirmation_strategy_context=confirmation_strategy_context,
)
def to_dict(self) -> dict[str, Any]:
"""
Serializes the BlockingConfirmationStrategy to a dictionary.
:returns:
Dictionary with serialized data.
"""
return default_to_dict(
self,
confirmation_policy=self.confirmation_policy.to_dict(),
confirmation_ui=self.confirmation_ui.to_dict(),
reject_template=self.reject_template,
modify_template=self.modify_template,
user_feedback_template=self.user_feedback_template,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "BlockingConfirmationStrategy":
"""
Deserializes the BlockingConfirmationStrategy from a dictionary.
:param data:
Dictionary to deserialize from.
:returns:
Deserialized BlockingConfirmationStrategy.
"""
deserialize_component_inplace(data["init_parameters"], key="confirmation_policy")
deserialize_component_inplace(data["init_parameters"], key="confirmation_ui")
return default_from_dict(cls, data)
def _get_confirmation_strategy(
*, tool_name: str, confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy]
) -> ConfirmationStrategy | None:
"""
Get the confirmation strategy for a given tool name.
:param tool_name:
The name of the tool to look up.
:param confirmation_strategies:
Dictionary of confirmation strategies with string or tuple keys. The `"*"` key, if present, is a wildcard
applied to any tool without a more specific entry.
:returns:
The confirmation strategy if found, None otherwise.
"""
if tool_name in confirmation_strategies:
return confirmation_strategies[tool_name]
for key, strategy in confirmation_strategies.items():
if isinstance(key, tuple) and tool_name in key:
return strategy
# Fall back to the wildcard entry that applies to any tool without a more specific match.
return confirmation_strategies.get("*")
def _passthrough_tool_call(tool_call: ToolCall) -> ToolExecutionDecision:
"""
Build a decision that executes a tool call as-is, bypassing confirmation.
Used for tool calls that don't resolve to a known tool (e.g. the model hallucinated the name). Instead of
raising here, the call is passed through unchanged so the tool-calling code resolves it and reports the
unknown tool uniformly (`ToolNotFoundException`, respecting `raise_on_failure`).
:param tool_call: The unresolved tool call to pass through.
:returns: A decision that executes the tool call with its original arguments.
"""
return ToolExecutionDecision(
tool_call_id=tool_call.id, tool_name=tool_call.tool_name, execute=True, final_tool_params=tool_call.arguments
)
def _process_confirmation_strategies(
*,
confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy],
messages_with_tool_calls: list[ChatMessage],
tools: list[Tool],
state: State,
confirmation_strategy_context: dict[str, Any] | None = None,
) -> list[ChatMessage]:
"""
Run the confirmation strategies and return the updated chat history.
The returned history ends with the confirmed/modified tool calls (preceded by any rejection messages), so the
pending tool calls to execute are always those on its last message.
:param confirmation_strategies: Mapping of tool names to their corresponding confirmation strategies
:param messages_with_tool_calls: Chat messages containing tool calls
:param tools: The available tools, used to resolve each tool call by name
:param state: The current runtime state, used to read the chat history
:param confirmation_strategy_context: Optional request-scoped context passed to the strategies
:returns:
The updated chat history.
"""
# If confirmations strategies is empty, return the chat history unchanged
if not confirmation_strategies:
return state.data["messages"]
# Run confirmation strategies and get tool execution decisions
teds = _run_confirmation_strategies(
confirmation_strategies=confirmation_strategies,
messages_with_tool_calls=messages_with_tool_calls,
tools=tools,
confirmation_strategy_context=confirmation_strategy_context,
)
# Apply tool execution decisions to messages_with_tool_calls
rejection_messages, modified_tool_call_messages = _apply_tool_execution_decisions(
tool_call_messages=messages_with_tool_calls, tool_execution_decisions=teds
)
# Update the chat history with rejection messages and new tool call messages
return _update_chat_history(
chat_history=state.data["messages"],
rejection_messages=rejection_messages,
tool_call_and_explanation_messages=modified_tool_call_messages,
)
async def _process_confirmation_strategies_async(
*,
confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy],
messages_with_tool_calls: list[ChatMessage],
tools: list[Tool],
state: State,
confirmation_strategy_context: dict[str, Any] | None = None,
) -> list[ChatMessage]:
"""
Async version of _process_confirmation_strategies.
Run the confirmation strategies and return the updated chat history.
The returned history ends with the confirmed/modified tool calls (preceded by any rejection messages), so the
pending tool calls to execute are always those on its last message.
:param confirmation_strategies: Mapping of tool names to their corresponding confirmation strategies
:param messages_with_tool_calls: Chat messages containing tool calls
:param tools: The available tools, used to resolve each tool call by name
:param state: The current runtime state, used to read the chat history
:param confirmation_strategy_context: Optional request-scoped context passed to the strategies
:returns:
The updated chat history.
"""
# If confirmations strategies is empty, return the chat history unchanged
if not confirmation_strategies:
return state.data["messages"]
# Run confirmation strategies and get tool execution decisions (async version)
teds = await _run_confirmation_strategies_async(
confirmation_strategies=confirmation_strategies,
messages_with_tool_calls=messages_with_tool_calls,
tools=tools,
confirmation_strategy_context=confirmation_strategy_context,
)
# Apply tool execution decisions to messages_with_tool_calls
rejection_messages, modified_tool_call_messages = _apply_tool_execution_decisions(
tool_call_messages=messages_with_tool_calls, tool_execution_decisions=teds
)
# Update the chat history with rejection messages and new tool call messages
return _update_chat_history(
chat_history=state.data["messages"],
rejection_messages=rejection_messages,
tool_call_and_explanation_messages=modified_tool_call_messages,
)
def _run_confirmation_strategies(
confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy],
messages_with_tool_calls: list[ChatMessage],
tools: list[Tool],
confirmation_strategy_context: dict[str, Any] | None = None,
) -> list[ToolExecutionDecision]:
"""
Run confirmation strategies for tool calls in the provided chat messages.
:param confirmation_strategies: Mapping of tool names to their corresponding confirmation strategies
:param messages_with_tool_calls: Messages containing tool calls to process
:param tools: The available tools, used to resolve each tool call by name
:param confirmation_strategy_context: Optional request-scoped context passed to the strategies
:returns:
A list of ToolExecutionDecision objects representing the decisions made for each tool call.
"""
tools_with_names = {tool.name: tool for tool in tools}
teds = []
for message in messages_with_tool_calls:
if not message.tool_calls:
continue
for tool_call in message.tool_calls:
tool_name = tool_call.tool_name
tool_to_invoke = tools_with_names.get(tool_name)
if tool_to_invoke is None:
# Unknown tool (e.g. the model hallucinated the name): skip confirmation and pass it through.
teds.append(_passthrough_tool_call(tool_call))
continue
# Confirm the model-requested arguments
final_args = dict(tool_call.arguments)
# Get tool execution decisions from confirmation strategies
# If no confirmation strategy is defined for this tool, proceed with execution
strategy = _get_confirmation_strategy(tool_name=tool_name, confirmation_strategies=confirmation_strategies)
if strategy is None:
teds.append(
ToolExecutionDecision(
tool_call_id=tool_call.id, tool_name=tool_name, execute=True, final_tool_params=final_args
)
)
continue
# Run the confirmation strategy
ted = strategy.run(
tool_name=tool_name,
tool_description=tool_to_invoke.description,
tool_params=final_args,
tool_call_id=tool_call.id,
confirmation_strategy_context=confirmation_strategy_context,
)
teds.append(ted)
return teds
async def _run_confirmation_strategies_async(
confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy],
messages_with_tool_calls: list[ChatMessage],
tools: list[Tool],
confirmation_strategy_context: dict[str, Any] | None = None,
) -> list[ToolExecutionDecision]:
"""
Async version of _run_confirmation_strategies.
Run confirmation strategies for tool calls in the provided chat messages.
:param confirmation_strategies: Mapping of tool names to their corresponding confirmation strategies
String keys map individual tools, tuple keys map multiple tools to the same strategy.
:param messages_with_tool_calls: Messages containing tool calls to process
:param tools: The available tools, used to resolve each tool call by name
:param confirmation_strategy_context: Optional request-scoped context passed to the strategies
:returns:
A list of ToolExecutionDecision objects representing the decisions made for each tool call.
"""
tools_with_names = {tool.name: tool for tool in tools}
teds = []
for message in messages_with_tool_calls:
if not message.tool_calls:
continue
for tool_call in message.tool_calls:
tool_name = tool_call.tool_name
tool_to_invoke = tools_with_names.get(tool_name)
if tool_to_invoke is None:
# Unknown tool (e.g. the model hallucinated the name): skip confirmation and pass it through.
teds.append(_passthrough_tool_call(tool_call))
continue
# Confirm the model-requested arguments
final_args = dict(tool_call.arguments)
# Get tool execution decisions from confirmation strategies
# If no confirmation strategy is defined for this tool, proceed with execution
strategy = _get_confirmation_strategy(tool_name=tool_name, confirmation_strategies=confirmation_strategies)
if strategy is None:
teds.append(
ToolExecutionDecision(
tool_call_id=tool_call.id, tool_name=tool_name, execute=True, final_tool_params=final_args
)
)
continue
# Use run_async if available, otherwise fall back to sync run
if hasattr(strategy, "run_async"):
ted = await strategy.run_async(
tool_name=tool_name,
tool_description=tool_to_invoke.description,
tool_params=final_args,
tool_call_id=tool_call.id,
confirmation_strategy_context=confirmation_strategy_context,
)
else:
ted = strategy.run(
tool_name=tool_name,
tool_description=tool_to_invoke.description,
tool_params=final_args,
tool_call_id=tool_call.id,
confirmation_strategy_context=confirmation_strategy_context,
)
teds.append(ted)
return teds
def _apply_tool_execution_decisions(
tool_call_messages: list[ChatMessage], tool_execution_decisions: list[ToolExecutionDecision]
) -> tuple[list[ChatMessage], list[ChatMessage]]:
"""
Apply the tool execution decisions to the tool call messages.
:param tool_call_messages: The tool call messages to apply the decisions to.
:param tool_execution_decisions: The tool execution decisions to apply.
:returns:
A tuple containing:
- A list of rejection messages for rejected tool calls. These are pairs of tool call and tool call result
messages.
- A list of tool call messages for confirmed or modified tool calls. If tool parameters were modified,
a user message explaining the modification is included before the tool call message.
"""
decision_by_id = {d.tool_call_id: d for d in tool_execution_decisions if d.tool_call_id}
decision_by_name = {d.tool_name: d for d in tool_execution_decisions if d.tool_name}
# Known limitation: If tool calls are missing IDs, we rely on tool names to match decisions to tool calls.
# This can lead to incorrect matches if there are multiple tool calls in the provided messages with duplicate names.
if not decision_by_id and len(decision_by_name) < len(tool_execution_decisions):
raise ValueError(
"ToolExecutionDecisions are missing tool_call_id fields and there are multiple tool calls with the same "
"name. When multiple tool calls with the same name are present, tool_call_id is required to correctly "
"match decisions to tool calls."
)
def make_assistant_message(chat_message: ChatMessage, tool_calls: list[ToolCall]) -> ChatMessage:
return ChatMessage.from_assistant(
text=chat_message.text,
meta=chat_message.meta,
name=chat_message.name,
tool_calls=tool_calls,
reasoning=chat_message.reasoning,
)
new_tool_call_messages = []
rejection_messages = []
for chat_msg in tool_call_messages:
new_tool_calls = []
for tc in chat_msg.tool_calls or []:
ted = decision_by_id.get(tc.id or "") or decision_by_name.get(tc.tool_name)
if not ted:
# This shouldn't happen, if so something went wrong in _run_confirmation_strategies
continue
if not ted.execute:
# rejected tool call
tool_result_text = ted.feedback or REJECTION_FEEDBACK_TEMPLATE.format(tool_name=tc.tool_name)
rejection_messages.extend(
[
make_assistant_message(chat_msg, [tc]),
ChatMessage.from_tool(tool_result=tool_result_text, origin=tc, error=True),
]
)
continue
# Covers confirm and modify cases
final_args = ted.final_tool_params or {}
if tc.arguments != final_args:
# In the modify case we add a user message explaining the modification otherwise the LLM won't know
# why the tool parameters changed and will likely just try and call the tool again with the
# original parameters.
user_text = ted.feedback or MODIFICATION_FEEDBACK_TEMPLATE.format(
tool_name=tc.tool_name, final_tool_params=final_args
)
new_tool_call_messages.append(ChatMessage.from_user(text=user_text))
new_tool_calls.append(replace(tc, arguments=final_args))
# Only add the tool call message if there are any tool calls left (i.e. not all were rejected)
if new_tool_calls:
new_tool_call_messages.append(make_assistant_message(chat_msg, new_tool_calls))
# new_tool_call_messages is a list of assistant messages with an optional preceding user message explaining
# modifications
# rejection_messages is a list of pairs of assistant and tool messages for rejected tool calls
return rejection_messages, new_tool_call_messages
def _update_chat_history(
chat_history: list[ChatMessage],
rejection_messages: list[ChatMessage],
tool_call_and_explanation_messages: list[ChatMessage],
) -> list[ChatMessage]:
"""
Update the chat history to include rejection messages and tool call messages at the appropriate positions.
Steps:
1. Identify the last user message and the last tool message in the current chat history.
2. Determine the insertion point as the maximum index of these two messages.
3. Create a new chat history that includes:
- All messages up to the insertion point.
- Any rejection messages (pairs of tool call and tool call result messages).
- Any tool call messages for confirmed or modified tool calls, including user messages explaining modifications.
:param chat_history: The current chat history.
:param rejection_messages: Chat messages to add for rejected tool calls (pairs of tool call and tool call result
messages).
:param tool_call_and_explanation_messages: Tool call messages for confirmed or modified tool calls, which may
include user messages explaining modifications.
:returns:
The updated chat history.
"""
user_indices = [i for i, message in enumerate(chat_history) if message.is_from("user")]
tool_indices = [i for i, message in enumerate(chat_history) if message.is_from("tool")]
last_user_idx = max(user_indices) if user_indices else -1
last_tool_idx = max(tool_indices) if tool_indices else -1
insertion_point = max(last_user_idx, last_tool_idx)
return chat_history[: insertion_point + 1] + rejection_messages + tool_call_and_explanation_messages
def _serialize_confirmation_strategies(
confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy],
) -> dict[str, Any]:
"""
Serialize a confirmation strategies dictionary to a plain, mapping-key-safe dictionary.
Mapping keys must be strings, so a tuple of tool names (one strategy shared across several tools) is encoded
as a JSON-array string (e.g. `("a", "b")` -> `'["a", "b"]'`); a single tool name is kept as-is.
:param confirmation_strategies: Mapping of tool name (or a tuple of tool names) to its strategy.
:returns: The same mapping with string keys and each strategy serialized to a dictionary.
"""
return {
(json.dumps(list(key)) if isinstance(key, tuple) else key): component_to_dict(
obj=strategy, name="confirmation_strategy"
)
for key, strategy in confirmation_strategies.items()
}
def _deserialize_confirmation_strategies(data: dict[str, Any]) -> dict[str | tuple[str, ...], ConfirmationStrategy]:
"""
Deserialize a confirmation strategies dictionary from its serialized form.
Deserializes each strategy component in-place and converts keys that were encoded as JSON-array strings (tuples
of tool names) back to tuples; single tool-name string keys are kept as-is.
:param data: Raw dictionary of serialized confirmation strategies, keyed by tool name(s).
:returns: Deserialized confirmation strategies with proper key types.
"""
for raw_key in list(data):
deserialize_component_inplace(data, key=raw_key)
return {_decode_strategy_key(raw_key): strategy for raw_key, strategy in data.items()}
def _decode_strategy_key(raw_key: str | list) -> str | tuple[str, ...]:
"""Reverse of the key encoding in `_serialize_confirmation_strategies`."""
# Backwards-compatibility: an actual list (older in-memory forms) becomes a tuple.
if isinstance(raw_key, list):
return tuple(raw_key)
# A JSON-array string encodes a tuple of tool names; any other string is a single tool name.
if raw_key.startswith("["):
return tuple(json.loads(raw_key))
return raw_key
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from .protocol import ConfirmationPolicy, ConfirmationStrategy, ConfirmationUI
__all__ = ["ConfirmationPolicy", "ConfirmationStrategy", "ConfirmationUI"]
@@ -0,0 +1,117 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any, Protocol
from haystack.core.serialization import default_from_dict, default_to_dict
from haystack.human_in_the_loop.dataclasses import ConfirmationUIResult, ToolExecutionDecision
class ConfirmationUI(Protocol):
"""Base class for confirmation UIs."""
def get_user_confirmation(
self, tool_name: str, tool_description: str, tool_params: dict[str, Any]
) -> ConfirmationUIResult:
"""Get user confirmation for tool execution."""
...
def to_dict(self) -> dict[str, Any]:
"""Serialize the UI to a dictionary."""
return default_to_dict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConfirmationUI":
"""Deserialize the ConfirmationUI from a dictionary."""
return default_from_dict(cls, data)
class ConfirmationPolicy(Protocol):
"""Base class for confirmation policies."""
def should_ask(self, tool_name: str, tool_description: str, tool_params: dict[str, Any]) -> bool:
"""Determine whether to ask for confirmation."""
...
def update_after_confirmation(
self,
tool_name: str, # noqa: ARG002
tool_description: str, # noqa: ARG002
tool_params: dict[str, Any], # noqa: ARG002
confirmation_result: ConfirmationUIResult, # noqa: ARG002
) -> None:
"""Update the policy based on the confirmation UI result."""
return
def to_dict(self) -> dict[str, Any]:
"""Serialize the policy to a dictionary."""
return default_to_dict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConfirmationPolicy":
"""Deserialize the policy from a dictionary."""
return default_from_dict(cls, data)
class ConfirmationStrategy(Protocol):
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:
"""
Run the confirmation strategy for a given tool and its parameters.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
:param tool_call_id: Optional unique identifier for the tool call. This can be used to track and correlate
the decision with a specific tool invocation.
:param confirmation_strategy_context: Optional context dictionary for passing request-scoped resources
(e.g., WebSocket connections, async queues) in web/server environments.
:returns:
The result of the confirmation strategy (e.g., tool output, rejection message, etc.).
"""
...
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:
"""
Async version of run. Run the confirmation strategy for a given tool and its parameters.
Default implementation calls the sync run() method. Override for true async behavior.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
:param tool_call_id: Optional unique identifier for the tool call. This can be used to track and correlate
the decision with a specific tool invocation.
:param confirmation_strategy_context: Optional context dictionary for passing request-scoped resources
(e.g., WebSocket connections, async queues) in web/server environments.
:returns:
The result of the confirmation strategy (e.g., tool output, rejection message, etc.).
"""
...
def to_dict(self) -> dict[str, Any]:
"""Serialize the strategy to a dictionary."""
...
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConfirmationStrategy":
"""Deserialize the strategy from a dictionary."""
...
@@ -0,0 +1,213 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
from threading import Lock
from typing import Any
from haystack.core.serialization import default_to_dict
from haystack.human_in_the_loop import ConfirmationUIResult
from haystack.human_in_the_loop.types import ConfirmationUI
from haystack.lazy_imports import LazyImport
with LazyImport(message="Run 'pip install rich'") as rich_import:
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
_ui_lock = Lock()
class RichConsoleUI(ConfirmationUI):
"""Rich console interface for user interaction."""
def __init__(self, console: "Console | None" = None) -> None:
"""Creates an instance of RichConsoleUI."""
rich_import.check()
self.console = console or Console()
def get_user_confirmation(
self, tool_name: str, tool_description: str, tool_params: dict[str, Any]
) -> ConfirmationUIResult:
"""
Get user confirmation for tool execution via rich console prompts.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
:returns: ConfirmationUIResult based on user input.
"""
with _ui_lock:
self._display_tool_info(tool_name, tool_description, tool_params)
# If wrong input is provided, Prompt.ask will re-prompt
choice = Prompt.ask("\nYour choice", choices=["y", "n", "m"], default="y", console=self.console)
return self._process_choice(choice, tool_params)
def _display_tool_info(self, tool_name: str, tool_description: str, tool_params: dict[str, Any]) -> None:
"""
Display tool information and parameters in a rich panel.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
"""
lines = [
f"[bold yellow]Tool:[/bold yellow] {tool_name}",
f"[bold yellow]Description:[/bold yellow] {tool_description}",
"\n[bold yellow]Arguments:[/bold yellow]",
]
if tool_params:
for k, v in tool_params.items():
lines.append(f"[cyan]{k}:[/cyan] {v}")
else:
lines.append(" (No arguments)")
self.console.print(Panel("\n".join(lines), title="🔧 Tool Execution Request", title_align="left"))
def _process_choice(self, choice: str, tool_params: dict[str, Any]) -> ConfirmationUIResult:
"""
Process the user's choice and return the corresponding ConfirmationUIResult.
:param choice: The user's choice ('y', 'n', or 'm').
:param tool_params: The original tool parameters.
:returns:
ConfirmationUIResult based on user input.
"""
if choice == "y":
return ConfirmationUIResult(action="confirm")
if choice == "m":
return self._modify_params(tool_params)
# reject
feedback = Prompt.ask("Feedback message (optional)", default="", console=self.console)
return ConfirmationUIResult(action="reject", feedback=feedback or None)
def _modify_params(self, tool_params: dict[str, Any]) -> ConfirmationUIResult:
"""
Prompt the user to modify tool parameters.
:param tool_params: The original tool parameters.
:returns:
ConfirmationUIResult with modified parameters.
"""
new_params: dict[str, Any] = {}
for k, v in tool_params.items():
# We don't JSON dump strings to avoid users needing to input extra quotes
default_val = json.dumps(v) if not isinstance(v, str) else v
while True:
new_val = Prompt.ask(f"Modify '{k}'", default=default_val, console=self.console)
try:
if isinstance(v, str):
# Always treat input as string
new_params[k] = new_val
else:
# Parse JSON for all non-string types
new_params[k] = json.loads(new_val)
break
except json.JSONDecodeError:
self.console.print("[red]❌ Invalid JSON, please try again.[/red]")
return ConfirmationUIResult(action="modify", new_tool_params=new_params)
def to_dict(self) -> dict[str, Any]:
"""
Serializes the RichConsoleConfirmationUI to a dictionary.
:returns:
Dictionary with serialized data.
"""
# Note: Console object is not serializable; we store None
return default_to_dict(self, console=None)
class SimpleConsoleUI(ConfirmationUI):
"""Simple console interface using standard input/output."""
def get_user_confirmation(
self, tool_name: str, tool_description: str, tool_params: dict[str, Any]
) -> ConfirmationUIResult:
"""
Get user confirmation for tool execution via simple console prompts.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
"""
with _ui_lock:
self._display_tool_info(tool_name, tool_description, tool_params)
valid_choices = {"y", "yes", "n", "no", "m", "modify"}
while True:
choice = input("Confirm execution? (y=confirm / n=reject / m=modify): ").strip().lower()
if choice in valid_choices:
break
print("Invalid input. Please enter 'y', 'n', or 'm'.")
return self._process_choice(choice, tool_params)
def _display_tool_info(self, tool_name: str, tool_description: str, tool_params: dict[str, Any]) -> None:
"""
Display tool information and parameters in the console.
:param tool_name: The name of the tool to be executed.
:param tool_description: The description of the tool.
:param tool_params: The parameters to be passed to the tool.
"""
print("\n--- Tool Execution Request ---")
print(f"Tool: {tool_name}")
print(f"Description: {tool_description}")
print("Arguments:")
if tool_params:
for k, v in tool_params.items():
print(f" {k}: {v}")
else:
print(" (No arguments)")
print("-" * 30)
def _process_choice(self, choice: str, tool_params: dict[str, Any]) -> ConfirmationUIResult:
"""
Process the user's choice and return the corresponding ConfirmationUIResult.
:param choice: The user's choice ('y', 'n', or 'm').
:param tool_params: The original tool parameters.
:returns:
ConfirmationUIResult based on user input.
"""
if choice in ("y", "yes"):
return ConfirmationUIResult(action="confirm")
if choice in ("m", "modify"):
return self._modify_params(tool_params)
# reject
feedback = input("Feedback message (optional): ").strip()
return ConfirmationUIResult(action="reject", feedback=feedback or None)
def _modify_params(self, tool_params: dict[str, Any]) -> ConfirmationUIResult:
"""
Prompt the user to modify tool parameters.
:param tool_params: The original tool parameters.
:returns:
ConfirmationUIResult with modified parameters.
"""
new_params: dict[str, Any] = {}
if not tool_params:
print("No parameters to modify, skipping modification.")
return ConfirmationUIResult(action="modify", new_tool_params=new_params)
for k, v in tool_params.items():
# We don't JSON dump strings to avoid users needing to input extra quotes
default_val = json.dumps(v) if not isinstance(v, str) else v
while True:
new_val = input(f"Modify '{k}' (current: {default_val}): ").strip() or default_val
try:
if isinstance(v, str):
# Always treat input as string
new_params[k] = new_val
else:
# Parse JSON for all non-string types
new_params[k] = json.loads(new_val)
break
except json.JSONDecodeError:
print("❌ Invalid JSON, please try again.")
return ConfirmationUIResult(action="modify", new_tool_params=new_params)