diff --git a/haystack/human_in_the_loop/dataclasses.py b/haystack/human_in_the_loop/dataclasses.py index 31947365fca..12865825280 100644 --- a/haystack/human_in_the_loop/dataclasses.py +++ b/haystack/human_in_the_loop/dataclasses.py @@ -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 @@ -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." + ) @dataclass @@ -45,6 +72,9 @@ 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 @@ -52,6 +82,26 @@ class ToolExecutionDecision: 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" + + 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 def to_dict(self) -> dict[str, Any]: """ diff --git a/haystack/human_in_the_loop/policies.py b/haystack/human_in_the_loop/policies.py index 46d2b002d39..dcd68c12c98 100644 --- a/haystack/human_in_the_loop/policies.py +++ b/haystack/human_in_the_loop/policies.py @@ -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 diff --git a/haystack/human_in_the_loop/strategies.py b/haystack/human_in_the_loop/strategies.py index 2d0a8da05aa..b09d2795659 100644 --- a/haystack/human_in_the_loop/strategies.py +++ b/haystack/human_in_the_loop/strategies.py @@ -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) @@ -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( @@ -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( @@ -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. diff --git a/releasenotes/notes/add-hitl-decision-status-semantics-phase3.yaml b/releasenotes/notes/add-hitl-decision-status-semantics-phase3.yaml new file mode 100644 index 00000000000..b990c53c912 --- /dev/null +++ b/releasenotes/notes/add-hitl-decision-status-semantics-phase3.yaml @@ -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. diff --git a/test/human_in_the_loop/test_dataclasses.py b/test/human_in_the_loop/test_dataclasses.py index daaabe1c032..679ae5ed21c 100644 --- a/test/human_in_the_loop/test_dataclasses.py +++ b/test/human_in_the_loop/test_dataclasses.py @@ -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: @@ -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( @@ -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): @@ -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"