Skip to content

Commit e792a31

Browse files
fix: stop silently clobbering tool execution decisions on name collisions
The ambiguity guard in _apply_tool_execution_decisions only checked whether *every* decision in the batch lacked a tool_call_id. If any unrelated tool call in the same batch had an id, the guard was skipped entirely, even though id-less duplicate-named calls were still present. The matching fallback (decision_by_name) would then silently resolve to the wrong decision, applying the wrong arguments to the wrong tool call with no error. The guard now checks whether any id-less decision's tool name collides with another decision anywhere in the batch, regardless of whether the colliding decision has an id, and raises ValueError in that case. Fixes #11756
1 parent 85ef972 commit e792a31

3 files changed

Lines changed: 76 additions & 2 deletions

File tree

haystack/human_in_the_loop/strategies.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,8 +514,18 @@ def _apply_tool_execution_decisions(
514514
decision_by_name = {d.tool_name: d for d in tool_execution_decisions if d.tool_name}
515515

516516
# Known limitation: If tool calls are missing IDs, we rely on tool names to match decisions to tool calls.
517-
# This can lead to incorrect matches if there are multiple tool calls in the provided messages with duplicate names.
518-
if not decision_by_id and len(decision_by_name) < len(tool_execution_decisions):
517+
# This can lead to incorrect matches if there are multiple tool calls in the provided messages with duplicate
518+
# names. `decision_by_name` is built from *all* decisions, so a name collision corrupts the name-based lookup
519+
# for an id-less decision even if the colliding decision has an id (it's still the id-less one that depends on
520+
# the now-ambiguous `decision_by_name` entry). So we check name duplicates across the whole batch, but only
521+
# raise if at least one decision sharing that name is missing a tool_call_id.
522+
name_occurrences: dict[str, int] = {}
523+
for decision in tool_execution_decisions:
524+
name_occurrences[decision.tool_name] = name_occurrences.get(decision.tool_name, 0) + 1
525+
526+
if any(
527+
name_occurrences[decision.tool_name] > 1 for decision in tool_execution_decisions if not decision.tool_call_id
528+
):
519529
raise ValueError(
520530
"ToolExecutionDecisions are missing tool_call_id fields and there are multiple tool calls with the same "
521531
"name. When multiple tool calls with the same name are present, tool_call_id is required to correctly "
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed `human_in_the_loop`'s `_apply_tool_execution_decisions` silently applying the wrong
5+
`ToolExecutionDecision` to a tool call when multiple tool calls shared the same tool name but lacked a
6+
`tool_call_id`. Previously, the ambiguity check was skipped if *any* unrelated tool call in the same batch
7+
happened to carry an id, allowing the wrong decision (and therefore the wrong arguments) to be silently
8+
applied. The check now correctly raises a `ValueError` whenever a tool name collision affects an id-less
9+
decision, regardless of other unrelated tool calls in the batch.

test/human_in_the_loop/test_strategies.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,61 @@ def test_two_teds_same_name_no_ids(self):
335335
],
336336
)
337337

338+
def test_two_teds_same_name_no_ids_plus_unrelated_call_with_id(self):
339+
# Same setup as test_two_teds_same_name_no_ids, but with an extra, unrelated tool call that *does* have an
340+
# id. The presence of that unrelated id must not bypass the ambiguity check for the id-less duplicates.
341+
message_with_tool_calls = ChatMessage.from_assistant(
342+
tool_calls=[
343+
ToolCall(tool_name="add_database_tool", arguments={"name": "Malte"}),
344+
ToolCall(tool_name="add_database_tool", arguments={"name": "Milos"}),
345+
ToolCall(tool_name="other_tool", arguments={}, id="xyz"),
346+
]
347+
)
348+
with pytest.raises(
349+
ValueError,
350+
match="ToolExecutionDecisions are missing tool_call_id fields and there are multiple tool calls with the "
351+
"same name",
352+
):
353+
_apply_tool_execution_decisions(
354+
tool_call_messages=[message_with_tool_calls],
355+
tool_execution_decisions=[
356+
ToolExecutionDecision(
357+
tool_name="add_database_tool", execute=True, final_tool_params={"name": "Malte"}
358+
),
359+
ToolExecutionDecision(
360+
tool_name="add_database_tool", execute=True, final_tool_params={"name": "Milos"}
361+
),
362+
ToolExecutionDecision(
363+
tool_name="other_tool", execute=True, tool_call_id="xyz", final_tool_params={}
364+
),
365+
],
366+
)
367+
368+
def test_two_teds_same_name_one_with_id_one_without(self):
369+
# One of the two same-named tool calls has an id, the other doesn't. The id-less one still falls back to
370+
# name-based matching against `decision_by_name`, which is corrupted by the name collision, so this must
371+
# also be treated as ambiguous even though only one of the two decisions lacks an id.
372+
message_with_tool_calls = ChatMessage.from_assistant(
373+
tool_calls=[
374+
ToolCall(tool_name="search", arguments={"q": "a"}),
375+
ToolCall(tool_name="search", arguments={"q": "b"}, id="1"),
376+
]
377+
)
378+
with pytest.raises(
379+
ValueError,
380+
match="ToolExecutionDecisions are missing tool_call_id fields and there are multiple tool calls with the "
381+
"same name",
382+
):
383+
_apply_tool_execution_decisions(
384+
tool_call_messages=[message_with_tool_calls],
385+
tool_execution_decisions=[
386+
ToolExecutionDecision(tool_name="search", execute=True, final_tool_params={"q": "a"}),
387+
ToolExecutionDecision(
388+
tool_name="search", execute=True, tool_call_id="1", final_tool_params={"q": "b"}
389+
),
390+
],
391+
)
392+
338393

339394
class TestUpdateChatHistory:
340395
@pytest.fixture

0 commit comments

Comments
 (0)