fix(backend): disambiguate duplicate tool names in OrchestratorBlock

When multiple nodes use the same block type, the orchestrator generated
identical tool names causing Anthropic API to reject with "Tool names
must be unique." Now appends _1, _2, etc. to duplicates and enriches
their descriptions with hardcoded defaults so the LLM can distinguish.
This commit is contained in:
Zamil Majdy
2026-03-25 16:31:35 +07:00
parent 995dd1b5f3
commit 270c2f0f55
2 changed files with 349 additions and 0 deletions

View File

@@ -507,6 +507,19 @@ class OrchestratorBlock(Block):
tool_function["_field_mapping"] = field_mapping
tool_function["_sink_node_id"] = sink_node.id
# Store hardcoded defaults (non-linked inputs) for disambiguation
linked_fields = {link.sink_name for link in links}
defaults = sink_node.input_default
tool_function["_hardcoded_defaults"] = (
{
k: v
for k, v in defaults.items()
if k not in linked_fields and not k.startswith("_")
}
if isinstance(defaults, dict)
else {}
)
return {"type": "function", "function": tool_function}
@staticmethod
@@ -581,6 +594,16 @@ class OrchestratorBlock(Block):
tool_function["_field_mapping"] = field_mapping
tool_function["_sink_node_id"] = sink_node.id
# Store hardcoded defaults (non-linked inputs) for disambiguation
linked_fields = {link.sink_name for link in links}
tool_function["_hardcoded_defaults"] = {
k: v
for k, v in sink_node.input_default.items()
if k not in linked_fields
and k not in ("graph_id", "graph_version", "input_schema")
and not k.startswith("_")
}
return {"type": "function", "function": tool_function}
@staticmethod
@@ -629,6 +652,39 @@ class OrchestratorBlock(Block):
)
return_tool_functions.append(tool_func)
# Disambiguate duplicate tool names (Anthropic API requires unique names)
name_counts: dict[str, int] = {}
for tool_func in return_tool_functions:
name = tool_func["function"]["name"]
name_counts[name] = name_counts.get(name, 0) + 1
name_seen: dict[str, int] = {}
for tool_func in return_tool_functions:
func = tool_func["function"]
name = func["name"]
if name_counts[name] > 1:
idx = name_seen.get(name, 0) + 1
name_seen[name] = idx
func["name"] = f"{name}_{idx}"
# Enrich description with hardcoded defaults so the LLM can
# distinguish between tools that share the same block type.
defaults = func.pop("_hardcoded_defaults", {})
if defaults:
defaults_summary = ", ".join(
f"{k}={json.dumps(v)}" for k, v in defaults.items()
)
func["description"] = (
f"{func.get('description', '')} "
f"[Pre-configured: {defaults_summary}]"
)
else:
func.pop("_hardcoded_defaults", None)
# Clean up _hardcoded_defaults from non-duplicate tools
for tool_func in return_tool_functions:
tool_func["function"].pop("_hardcoded_defaults", None)
return return_tool_functions
async def _attempt_llm_call_with_validation(

View File

@@ -0,0 +1,293 @@
"""Tests for OrchestratorBlock tool name disambiguation.
When multiple nodes use the same block type, their tool names collide.
The Anthropic API requires unique tool names, so the orchestrator must
disambiguate them and enrich descriptions with hardcoded defaults.
"""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from backend.blocks.orchestrator import OrchestratorBlock
from backend.blocks.text import MatchTextPatternBlock
def _make_mock_node(
block,
node_id: str,
input_default: dict | None = None,
metadata: dict | None = None,
):
"""Create a mock Node with the given block and defaults."""
node = Mock()
node.block = block
node.block_id = block.id
node.id = node_id
node.input_default = input_default or {}
node.metadata = metadata or {}
return node
def _make_mock_link(source_name: str, sink_name: str, sink_id: str, source_id: str):
"""Create a mock Link."""
return Mock(
source_name=source_name,
sink_name=sink_name,
sink_id=sink_id,
source_id=source_id,
)
@pytest.mark.asyncio
async def test_duplicate_block_names_get_suffixed():
"""Two nodes using the same block type should produce unique tool names."""
block = MatchTextPatternBlock()
node_a = _make_mock_node(block, "node_a", input_default={"match": "foo"})
node_b = _make_mock_node(block, "node_b", input_default={"match": "bar"})
link_a = _make_mock_link("tools_^_a_~_text", "text", "node_a", "orch")
link_b = _make_mock_link("tools_^_b_~_text", "text", "node_b", "orch")
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = [
(link_a, node_a),
(link_b, node_b),
]
with patch(
"backend.blocks.orchestrator.get_database_manager_async_client",
return_value=mock_db,
):
tools = await OrchestratorBlock._create_tool_node_signatures("orch")
names = [t["function"]["name"] for t in tools]
assert len(names) == 2
assert len(set(names)) == 2, f"Tool names are not unique: {names}"
# Should be suffixed with _1, _2
base = OrchestratorBlock.cleanup(block.name)
assert f"{base}_1" in names
assert f"{base}_2" in names
@pytest.mark.asyncio
async def test_duplicate_tools_include_defaults_in_description():
"""Duplicate tools should have hardcoded defaults in description."""
block = MatchTextPatternBlock()
node_a = _make_mock_node(
block, "node_a", input_default={"match": "error", "case_sensitive": True}
)
node_b = _make_mock_node(
block, "node_b", input_default={"match": "warning", "case_sensitive": False}
)
link_a = _make_mock_link("tools_^_a_~_text", "text", "node_a", "orch")
link_b = _make_mock_link("tools_^_b_~_text", "text", "node_b", "orch")
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = [
(link_a, node_a),
(link_b, node_b),
]
with patch(
"backend.blocks.orchestrator.get_database_manager_async_client",
return_value=mock_db,
):
tools = await OrchestratorBlock._create_tool_node_signatures("orch")
# Find each tool by suffix
tool_1 = next(t for t in tools if t["function"]["name"].endswith("_1"))
tool_2 = next(t for t in tools if t["function"]["name"].endswith("_2"))
# Descriptions should contain the hardcoded defaults (not the linked 'text' field)
assert "[Pre-configured:" in tool_1["function"]["description"]
assert "[Pre-configured:" in tool_2["function"]["description"]
assert '"error"' in tool_1["function"]["description"]
assert '"warning"' in tool_2["function"]["description"]
@pytest.mark.asyncio
async def test_unique_tool_names_unchanged():
"""When all tool names are already unique, no suffixing should occur."""
block_a = MatchTextPatternBlock()
node_a = _make_mock_node(
block_a, "node_a", metadata={"customized_name": "search_errors"}
)
node_b = _make_mock_node(
block_a, "node_b", metadata={"customized_name": "search_warnings"}
)
link_a = _make_mock_link("tools_^_a_~_text", "text", "node_a", "orch")
link_b = _make_mock_link("tools_^_b_~_text", "text", "node_b", "orch")
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = [
(link_a, node_a),
(link_b, node_b),
]
with patch(
"backend.blocks.orchestrator.get_database_manager_async_client",
return_value=mock_db,
):
tools = await OrchestratorBlock._create_tool_node_signatures("orch")
names = [t["function"]["name"] for t in tools]
assert "search_errors" in names
assert "search_warnings" in names
# No suffixing
assert all("_1" not in n and "_2" not in n for n in names)
@pytest.mark.asyncio
async def test_no_hardcoded_defaults_key_leaks_to_tool_schema():
"""_hardcoded_defaults should be cleaned up and not sent to the LLM API."""
block = MatchTextPatternBlock()
node_a = _make_mock_node(block, "node_a", input_default={"match": "foo"})
node_b = _make_mock_node(block, "node_b", input_default={"match": "bar"})
link_a = _make_mock_link("tools_^_a_~_text", "text", "node_a", "orch")
link_b = _make_mock_link("tools_^_b_~_text", "text", "node_b", "orch")
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = [
(link_a, node_a),
(link_b, node_b),
]
with patch(
"backend.blocks.orchestrator.get_database_manager_async_client",
return_value=mock_db,
):
tools = await OrchestratorBlock._create_tool_node_signatures("orch")
for tool in tools:
assert "_hardcoded_defaults" not in tool["function"]
@pytest.mark.asyncio
async def test_single_tool_no_suffixing():
"""A single tool should never get suffixed."""
block = MatchTextPatternBlock()
node = _make_mock_node(block, "node_a", input_default={"match": "foo"})
link = _make_mock_link("tools_^_a_~_text", "text", "node_a", "orch")
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = [(link, node)]
with patch(
"backend.blocks.orchestrator.get_database_manager_async_client",
return_value=mock_db,
):
tools = await OrchestratorBlock._create_tool_node_signatures("orch")
assert len(tools) == 1
name = tools[0]["function"]["name"]
assert not name.endswith("_1")
assert not name.endswith("_2")
# No Pre-configured in description for single tools
assert "[Pre-configured:" not in tools[0]["function"].get("description", "")
@pytest.mark.asyncio
async def test_three_duplicates_all_get_unique_names():
"""Three nodes with same block type should all get unique suffixed names."""
block = MatchTextPatternBlock()
nodes_and_links = []
for i, pattern in enumerate(["error", "warning", "info"]):
node = _make_mock_node(block, f"node_{i}", input_default={"match": pattern})
link = _make_mock_link(f"tools_^_{i}_~_text", "text", f"node_{i}", "orch")
nodes_and_links.append((link, node))
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = nodes_and_links
with patch(
"backend.blocks.orchestrator.get_database_manager_async_client",
return_value=mock_db,
):
tools = await OrchestratorBlock._create_tool_node_signatures("orch")
names = [t["function"]["name"] for t in tools]
assert len(names) == 3
assert len(set(names)) == 3, f"Tool names are not unique: {names}"
base = OrchestratorBlock.cleanup(block.name)
assert f"{base}_1" in names
assert f"{base}_2" in names
assert f"{base}_3" in names
@pytest.mark.asyncio
async def test_linked_fields_excluded_from_defaults():
"""Fields that are linked (LLM provides them) should not appear in defaults."""
block = MatchTextPatternBlock()
# 'text' is linked, 'match' and 'case_sensitive' are hardcoded
node_a = _make_mock_node(
block,
"node_a",
input_default={"text": "ignored", "match": "error", "case_sensitive": True},
)
# Duplicate to trigger disambiguation
node_b = _make_mock_node(
block, "node_b", input_default={"text": "ignored", "match": "warning"}
)
link_a = _make_mock_link("tools_^_a_~_text", "text", "node_a", "orch")
link_b = _make_mock_link("tools_^_b_~_text", "text", "node_b", "orch")
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = [
(link_a, node_a),
(link_b, node_b),
]
with patch(
"backend.blocks.orchestrator.get_database_manager_async_client",
return_value=mock_db,
):
tools = await OrchestratorBlock._create_tool_node_signatures("orch")
tool_1 = next(t for t in tools if t["function"]["name"].endswith("_1"))
desc = tool_1["function"]["description"]
# 'text' is linked so should NOT appear in Pre-configured
assert "text=" not in desc
# 'match' is hardcoded so should appear
assert "match=" in desc
@pytest.mark.asyncio
async def test_mixed_unique_and_duplicate_names():
"""Only duplicate names get suffixed; unique names are left untouched."""
block_a = MatchTextPatternBlock()
node_a1 = _make_mock_node(block_a, "node_a1", input_default={"match": "foo"})
node_a2 = _make_mock_node(block_a, "node_a2", input_default={"match": "bar"})
# Use a different block with a custom name to be unique
node_b = _make_mock_node(
block_a, "node_b", metadata={"customized_name": "unique_tool"}
)
link_a1 = _make_mock_link("tools_^_a1_~_text", "text", "node_a1", "orch")
link_a2 = _make_mock_link("tools_^_a2_~_text", "text", "node_a2", "orch")
link_b = _make_mock_link("tools_^_b_~_text", "text", "node_b", "orch")
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = [
(link_a1, node_a1),
(link_a2, node_a2),
(link_b, node_b),
]
with patch(
"backend.blocks.orchestrator.get_database_manager_async_client",
return_value=mock_db,
):
tools = await OrchestratorBlock._create_tool_node_signatures("orch")
names = [t["function"]["name"] for t in tools]
assert len(set(names)) == 3
assert "unique_tool" in names
base = OrchestratorBlock.cleanup(block_a.name)
assert f"{base}_1" in names
assert f"{base}_2" in names