Skip to content

Commit 3db10d8

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 0500a97 commit 3db10d8

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
@@ -544,8 +544,18 @@ def _apply_tool_execution_decisions(
544544
decision_by_name = {d.tool_name: d for d in tool_execution_decisions if d.tool_name}
545545

546546
# Known limitation: If tool calls are missing IDs, we rely on tool names to match decisions to tool calls.
547-
# This can lead to incorrect matches if there are multiple tool calls in the provided messages with duplicate names.
548-
if not decision_by_id and len(decision_by_name) < len(tool_execution_decisions):
547+
# This can lead to incorrect matches if there are multiple tool calls in the provided messages with duplicate
548+
# names. `decision_by_name` is built from *all* decisions, so a name collision corrupts the name-based lookup
549+
# for an id-less decision even if the colliding decision has an id (it's still the id-less one that depends on
550+
# the now-ambiguous `decision_by_name` entry). So we check name duplicates across the whole batch, but only
551+
# raise if at least one decision sharing that name is missing a tool_call_id.
552+
name_occurrences: dict[str, int] = {}
553+
for decision in tool_execution_decisions:
554+
name_occurrences[decision.tool_name] = name_occurrences.get(decision.tool_name, 0) + 1
555+
556+
if any(
557+
name_occurrences[decision.tool_name] > 1 for decision in tool_execution_decisions if not decision.tool_call_id
558+
):
549559
raise ValueError(
550560
"ToolExecutionDecisions are missing tool_call_id fields and there are multiple tool calls with the same "
551561
"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
@@ -357,6 +357,61 @@ def test_two_teds_same_name_no_ids(self):
357357
],
358358
)
359359

360+
def test_two_teds_same_name_no_ids_plus_unrelated_call_with_id(self):
361+
# Same setup as test_two_teds_same_name_no_ids, but with an extra, unrelated tool call that *does* have an
362+
# id. The presence of that unrelated id must not bypass the ambiguity check for the id-less duplicates.
363+
message_with_tool_calls = ChatMessage.from_assistant(
364+
tool_calls=[
365+
ToolCall(tool_name="add_database_tool", arguments={"name": "Malte"}),
366+
ToolCall(tool_name="add_database_tool", arguments={"name": "Milos"}),
367+
ToolCall(tool_name="other_tool", arguments={}, id="xyz"),
368+
]
369+
)
370+
with pytest.raises(
371+
ValueError,
372+
match="ToolExecutionDecisions are missing tool_call_id fields and there are multiple tool calls with the "
373+
"same name",
374+
):
375+
_apply_tool_execution_decisions(
376+
tool_call_messages=[message_with_tool_calls],
377+
tool_execution_decisions=[
378+
ToolExecutionDecision(
379+
tool_name="add_database_tool", execute=True, final_tool_params={"name": "Malte"}
380+
),
381+
ToolExecutionDecision(
382+
tool_name="add_database_tool", execute=True, final_tool_params={"name": "Milos"}
383+
),
384+
ToolExecutionDecision(
385+
tool_name="other_tool", execute=True, tool_call_id="xyz", final_tool_params={}
386+
),
387+
],
388+
)
389+
390+
def test_two_teds_same_name_one_with_id_one_without(self):
391+
# One of the two same-named tool calls has an id, the other doesn't. The id-less one still falls back to
392+
# name-based matching against `decision_by_name`, which is corrupted by the name collision, so this must
393+
# also be treated as ambiguous even though only one of the two decisions lacks an id.
394+
message_with_tool_calls = ChatMessage.from_assistant(
395+
tool_calls=[
396+
ToolCall(tool_name="search", arguments={"q": "a"}),
397+
ToolCall(tool_name="search", arguments={"q": "b"}, id="1"),
398+
]
399+
)
400+
with pytest.raises(
401+
ValueError,
402+
match="ToolExecutionDecisions are missing tool_call_id fields and there are multiple tool calls with the "
403+
"same name",
404+
):
405+
_apply_tool_execution_decisions(
406+
tool_call_messages=[message_with_tool_calls],
407+
tool_execution_decisions=[
408+
ToolExecutionDecision(tool_name="search", execute=True, final_tool_params={"q": "a"}),
409+
ToolExecutionDecision(
410+
tool_name="search", execute=True, tool_call_id="1", final_tool_params={"q": "b"}
411+
),
412+
],
413+
)
414+
360415

361416
class TestUpdateChatHistory:
362417
@pytest.fixture

0 commit comments

Comments
 (0)