Files
wehub-resource-sync c56bef871b
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
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

625 lines
27 KiB
Python

# 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