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,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)
|
||||
Reference in New Issue
Block a user