chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user