Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Commit 35656e2

Browse files
feat: add explicit HITL decision statuses
1 parent 64efaa7 commit 35656e2

5 files changed

Lines changed: 96 additions & 8 deletions

File tree

haystack/human_in_the_loop/dataclasses.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
from dataclasses import asdict, dataclass
6-
from typing import Any
6+
from typing import Any, Literal
7+
8+
DecisionStatus = Literal["approved", "modified", "rejected"]
79

810

911
@dataclass
@@ -20,11 +22,36 @@ class ConfirmationUIResult:
2022
:param new_tool_params:
2123
Optional set of new parameters for the tool. For example, if the user chooses to modify the tool parameters,
2224
they can provide a new set of parameters here.
25+
:param status:
26+
Optional explicit decision status. When omitted, built-in actions are normalized as:
27+
`confirm` -> `approved`, `modify` -> `modified`, `reject` -> `rejected`.
2328
"""
2429

2530
action: str # "confirm", "reject", "modify"
2631
feedback: str | None = None
2732
new_tool_params: dict[str, Any] | None = None
33+
status: DecisionStatus | None = None
34+
35+
def resolved_status(self) -> DecisionStatus:
36+
"""
37+
Resolve the explicit decision status for this UI result.
38+
39+
:raises ValueError:
40+
If neither a supported built-in action nor an explicit status is provided.
41+
"""
42+
if self.status is not None:
43+
return self.status
44+
45+
if self.action == "confirm":
46+
return "approved"
47+
if self.action == "modify":
48+
return "modified"
49+
if self.action == "reject":
50+
return "rejected"
51+
52+
raise ValueError(
53+
"Unsupported confirmation action. Provide one of 'confirm', 'modify', 'reject' or set 'status' explicitly."
54+
)
2855

2956

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

5080
tool_name: str
5181
execute: bool
5282
tool_call_id: str | None = None
5383
feedback: str | None = None
5484
final_tool_params: dict[str, Any] | None = None
85+
status: DecisionStatus | None = None
86+
87+
def __post_init__(self) -> None:
88+
if self.status is None:
89+
self.status = self._infer_status()
90+
else:
91+
self.execute = self.status != "rejected"
92+
93+
def _infer_status(self) -> DecisionStatus:
94+
if not self.execute:
95+
return "rejected"
96+
if self.feedback is not None and self.final_tool_params is not None:
97+
return "modified"
98+
return "approved"
99+
100+
def resolved_status(self) -> DecisionStatus:
101+
"""
102+
Return the normalized decision status for this execution decision.
103+
"""
104+
return self.status
55105

56106
def to_dict(self) -> dict[str, Any]:
57107
"""

haystack/human_in_the_loop/policies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,5 @@ def update_after_confirmation(
7575
:param tool_params: The parameters that were passed to the tool.
7676
:param confirmation_result: The result from the confirmation UI.
7777
"""
78-
if confirmation_result.action == "confirm":
78+
if confirmation_result.resolved_status() == "approved":
7979
self._asked_tools[tool_name] = tool_params

haystack/human_in_the_loop/strategies.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,21 @@ def run(
107107
)
108108

109109
# Process the confirmation result
110+
decision_status = confirmation_ui_result.resolved_status()
110111
final_args = {}
111-
if confirmation_ui_result.action == "reject":
112+
if decision_status == "rejected":
112113
explanation_text = self.reject_template.format(tool_name=tool_name)
113114
if confirmation_ui_result.feedback:
114115
explanation_text += " "
115116
explanation_text += self.user_feedback_template.format(feedback=confirmation_ui_result.feedback)
116117
return ToolExecutionDecision(
117-
tool_name=tool_name, execute=False, tool_call_id=tool_call_id, feedback=explanation_text
118+
tool_name=tool_name,
119+
execute=False,
120+
tool_call_id=tool_call_id,
121+
feedback=explanation_text,
122+
status="rejected",
118123
)
119-
if confirmation_ui_result.action == "modify" and confirmation_ui_result.new_tool_params:
124+
if decision_status == "modified" and confirmation_ui_result.new_tool_params:
120125
# Update the tool call params with the new params
121126
final_args.update(confirmation_ui_result.new_tool_params)
122127
explanation_text = self.modify_template.format(tool_name=tool_name, final_tool_params=final_args)
@@ -129,10 +134,15 @@ def run(
129134
execute=True,
130135
feedback=explanation_text,
131136
final_tool_params=final_args,
137+
status="modified",
132138
)
133139
# action == "confirm"
134140
return ToolExecutionDecision(
135-
tool_name=tool_name, execute=True, tool_call_id=tool_call_id, final_tool_params=tool_params
141+
tool_name=tool_name,
142+
execute=True,
143+
tool_call_id=tool_call_id,
144+
final_tool_params=tool_params,
145+
status="approved",
136146
)
137147

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

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

556566
# Covers confirm and modify cases
557567
final_args = ted.final_tool_params or {}
558-
if tc.arguments != final_args:
568+
if ted.resolved_status() == "modified" or tc.arguments != final_args:
559569
# In the modify case we add a user message explaining the modification otherwise the LLM won't know
560570
# why the tool parameters changed and will likely just try and call the tool again with the
561571
# original parameters.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
enhancements:
3+
- |
4+
Adds an explicit `status` field to `ToolExecutionDecision` with the values
5+
`approved`, `modified`, and `rejected`, while preserving the legacy
6+
`execute` boolean for backward compatibility. `BlockingConfirmationStrategy`
7+
now normalizes built-in confirmation UI actions into these explicit HITL
8+
decision statuses.

test/human_in_the_loop/test_dataclasses.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ def test_init(self):
1111
assert original.action == "reject"
1212
assert original.feedback == "Changed my mind"
1313
assert original.new_tool_params is None
14+
assert original.resolved_status() == "rejected"
15+
16+
def test_resolved_status_from_explicit_status(self):
17+
original = ConfirmationUIResult(action="custom", status="modified")
18+
assert original.resolved_status() == "modified"
1419

1520

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

2935
def test_to_dict(self):
3036
original = ToolExecutionDecision(
@@ -40,6 +46,7 @@ def test_to_dict(self):
4046
"tool_call_id": "test_tool_call_id",
4147
"feedback": None,
4248
"final_tool_params": {"param1": "new_value"},
49+
"status": "approved",
4350
}
4451

4552
def test_from_dict(self):
@@ -56,3 +63,16 @@ def test_from_dict(self):
5663
assert decision.tool_call_id == "another_tool_call_id"
5764
assert decision.feedback == "Not needed"
5865
assert decision.final_tool_params == {"paramA": 123}
66+
assert decision.status == "rejected"
67+
68+
def test_from_dict_legacy_payload_without_status(self):
69+
data = {
70+
"execute": True,
71+
"tool_name": "legacy_tool",
72+
"tool_call_id": "legacy-tool-call-id",
73+
"feedback": "The parameters for tool 'legacy_tool' were updated by the user to:\n{'x': 2}",
74+
"final_tool_params": {"x": 2},
75+
}
76+
decision = ToolExecutionDecision.from_dict(data)
77+
assert decision.execute is True
78+
assert decision.status == "modified"

0 commit comments

Comments
 (0)