Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion haystack/human_in_the_loop/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
# SPDX-License-Identifier: Apache-2.0

from dataclasses import asdict, dataclass
from typing import Any
from typing import Any, Literal

DecisionStatus = Literal["approved", "modified", "rejected"]


@dataclass
Expand All @@ -20,11 +22,36 @@ class ConfirmationUIResult:
: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.
:param status:
Optional explicit decision status. When omitted, built-in actions are normalized as:
`confirm` -> `approved`, `modify` -> `modified`, `reject` -> `rejected`.
"""

action: str # "confirm", "reject", "modify"
feedback: str | None = None
new_tool_params: dict[str, Any] | None = None
status: DecisionStatus | None = None

def resolved_status(self) -> DecisionStatus:
"""
Resolve the explicit decision status for this UI result.

:raises ValueError:
If neither a supported built-in action nor an explicit status is provided.
"""
if self.status is not None:
return self.status

if self.action == "confirm":
return "approved"
if self.action == "modify":
return "modified"
if self.action == "reject":
return "rejected"

raise ValueError(
"Unsupported confirmation action. Provide one of 'confirm', 'modify', 'reject' or set 'status' explicitly."
)
Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error message could be more helpful by including the actual unsupported action and listing the valid values for the status field. This assists developers in debugging custom ConfirmationUI implementations.

Suggested change
raise ValueError(
"Unsupported confirmation action. Provide one of 'confirm', 'modify', 'reject' or set 'status' explicitly."
)
raise ValueError(
f"Unsupported confirmation action '{self.action}'. "
"Provide one of 'confirm', 'modify', 'reject' or set 'status' to one of "
"'approved', 'modified', 'rejected' explicitly."
)



@dataclass
Expand All @@ -45,13 +72,36 @@ class ToolExecutionDecision:
modified, this can contain the modification details.
:param final_tool_params:
Optional final parameters for the tool if execution is confirmed or modified.
:param status:
Explicit decision status. When omitted, it is inferred from the legacy `execute`/`feedback` shape to remain
backward-compatible with older serialized payloads.
"""

tool_name: str
execute: bool
tool_call_id: str | None = None
feedback: str | None = None
final_tool_params: dict[str, Any] | None = None
status: DecisionStatus | None = None

def __post_init__(self) -> None:
if self.status is None:
self.status = self._infer_status()
else:
self.execute = self.status != "rejected"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate status before overriding execute

When a ToolExecutionDecision is rebuilt from an external or serialized dict and status is misspelled or from a newer value, for example {'execute': False, 'status': 'reject'}, this branch treats anything other than exactly "rejected" as executable and overwrites execute to True. That fail-open path can run a tool that the caller explicitly marked as not executable, so unknown statuses should be rejected or fall back to the legacy execute value instead of approving execution.

Useful? React with 👍 / 👎.


def _infer_status(self) -> DecisionStatus:
if not self.execute:
return "rejected"
if self.feedback is not None and self.final_tool_params is not None:
return "modified"
return "approved"

def resolved_status(self) -> DecisionStatus:
"""
Return the normalized decision status for this execution decision.
"""
return self.status
Comment on lines +100 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The resolved_status method currently returns self.status directly. Since self.status is typed as DecisionStatus | None, this could return None if the object was mutated (e.g., via dataclasses.replace) or if __post_init__ was bypassed. To ensure type safety and robustness, it's better to fall back to _infer_status() if self.status is missing.

Suggested change
def resolved_status(self) -> DecisionStatus:
"""
Return the normalized decision status for this execution decision.
"""
return self.status
def resolved_status(self) -> DecisionStatus:
"""
Return the normalized decision status for this execution decision.
"""
return self.status or self._infer_status()


def to_dict(self) -> dict[str, Any]:
"""
Expand Down
2 changes: 1 addition & 1 deletion haystack/human_in_the_loop/policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,5 @@ def update_after_confirmation(
: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":
if confirmation_result.resolved_status() == "approved":
self._asked_tools[tool_name] = tool_params
22 changes: 16 additions & 6 deletions haystack/human_in_the_loop/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,21 @@ def run(
)

# Process the confirmation result
decision_status = confirmation_ui_result.resolved_status()
final_args = {}
if confirmation_ui_result.action == "reject":
if decision_status == "rejected":
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
tool_name=tool_name,
execute=False,
tool_call_id=tool_call_id,
feedback=explanation_text,
status="rejected",
)
if confirmation_ui_result.action == "modify" and confirmation_ui_result.new_tool_params:
if decision_status == "modified" 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)
Expand All @@ -129,10 +134,15 @@ def run(
execute=True,
feedback=explanation_text,
final_tool_params=final_args,
status="modified",
)
# action == "confirm"
return ToolExecutionDecision(
tool_name=tool_name, execute=True, tool_call_id=tool_call_id, final_tool_params=tool_params
tool_name=tool_name,
execute=True,
tool_call_id=tool_call_id,
final_tool_params=tool_params,
status="approved",
)

async def run_async(
Expand Down Expand Up @@ -542,7 +552,7 @@ def make_assistant_message(chat_message: ChatMessage, tool_calls: list[ToolCall]
# This shouldn't happen, if so something went wrong in _run_confirmation_strategies
continue

if not ted.execute:
if ted.resolved_status() == "rejected":
# rejected tool call
tool_result_text = ted.feedback or REJECTION_FEEDBACK_TEMPLATE.format(tool_name=tc.tool_name)
rejection_messages.extend(
Expand All @@ -555,7 +565,7 @@ def make_assistant_message(chat_message: ChatMessage, tool_calls: list[ToolCall]

# Covers confirm and modify cases
final_args = ted.final_tool_params or {}
if tc.arguments != final_args:
if ted.resolved_status() == "modified" or 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
enhancements:
- |
Adds an explicit `status` field to `ToolExecutionDecision` with the values
`approved`, `modified`, and `rejected`, while preserving the legacy
`execute` boolean for backward compatibility. `BlockingConfirmationStrategy`
now normalizes built-in confirmation UI actions into these explicit HITL
decision statuses.
20 changes: 20 additions & 0 deletions test/human_in_the_loop/test_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ def test_init(self):
assert original.action == "reject"
assert original.feedback == "Changed my mind"
assert original.new_tool_params is None
assert original.resolved_status() == "rejected"

def test_resolved_status_from_explicit_status(self):
original = ConfirmationUIResult(action="custom", status="modified")
assert original.resolved_status() == "modified"


class TestToolExecutionDecision:
Expand All @@ -25,6 +30,7 @@ def test_init(self):
assert decision.final_tool_params == {"param1": "new_value"}
assert decision.tool_call_id == "test_tool_call_id"
assert decision.tool_name == "test_tool"
assert decision.status == "approved"

def test_to_dict(self):
original = ToolExecutionDecision(
Expand All @@ -40,6 +46,7 @@ def test_to_dict(self):
"tool_call_id": "test_tool_call_id",
"feedback": None,
"final_tool_params": {"param1": "new_value"},
"status": "approved",
}

def test_from_dict(self):
Expand All @@ -56,3 +63,16 @@ def test_from_dict(self):
assert decision.tool_call_id == "another_tool_call_id"
assert decision.feedback == "Not needed"
assert decision.final_tool_params == {"paramA": 123}
assert decision.status == "rejected"

def test_from_dict_legacy_payload_without_status(self):
data = {
"execute": True,
"tool_name": "legacy_tool",
"tool_call_id": "legacy-tool-call-id",
"feedback": "The parameters for tool 'legacy_tool' were updated by the user to:\n{'x': 2}",
"final_tool_params": {"x": 2},
}
decision = ToolExecutionDecision.from_dict(data)
assert decision.execute is True
assert decision.status == "modified"
Loading