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
135 lines
5.9 KiB
Python
135 lines
5.9 KiB
Python
# 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)
|