fix(backend): prevent suffix collision with user-named tools

The dedup logic now checks the full set of existing tool names before
assigning a suffix. If a user-defined tool already uses e.g. 'my_tool_1',
the algorithm skips to '_2'. Fixes the edge case flagged by Sentry.
This commit is contained in:
Zamil Majdy
2026-03-25 16:42:58 +07:00
parent 823eb3d15a
commit f4d6bc1f5b
2 changed files with 55 additions and 7 deletions

View File

@@ -676,17 +676,27 @@ class OrchestratorBlock(Block):
name = tool_func["function"]["name"]
name_counts[name] = name_counts.get(name, 0) + 1
name_seen: dict[str, int] = {}
# Collect all final names to avoid collisions with user-defined names
all_names: set[str] = {
tool_func["function"]["name"] for tool_func in return_tool_functions
}
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
suffix = f"_{idx}"
# Anthropic tool names have a 64-char limit
max_base = 64 - len(suffix)
func["name"] = f"{name[:max_base]}{suffix}"
# Find the next available suffix that doesn't collide
idx = 1
while True:
suffix = f"_{idx}"
# Anthropic tool names have a 64-char limit
max_base = 64 - len(suffix)
candidate = f"{name[:max_base]}{suffix}"
if candidate not in all_names:
break
idx += 1
func["name"] = candidate
all_names.add(candidate)
# Enrich description with hardcoded defaults so the LLM can
# distinguish between tools that share the same block type.

View File

@@ -363,3 +363,41 @@ async def test_long_tool_name_truncated():
for tool in tools:
name = tool["function"]["name"]
assert len(name) <= 64, f"Tool name exceeds 64 chars: {name!r} ({len(name)})"
@pytest.mark.asyncio
async def test_suffix_collision_with_user_named_tool():
"""If a user-named tool is 'my_tool_1', dedup of 'my_tool' should skip to _2."""
block = MatchTextPatternBlock()
# Two nodes with same block name (will collide)
node_a = _make_mock_node(block, "node_a", input_default={"match": "foo"})
node_b = _make_mock_node(block, "node_b", input_default={"match": "bar"})
# A third node that a user has customized to match the _1 suffix pattern
base = OrchestratorBlock.cleanup(block.name)
node_c = _make_mock_node(block, "node_c", metadata={"customized_name": f"{base}_1"})
link_a = _make_mock_link("tools_^_a_~_text", "text", "node_a", "orch")
link_b = _make_mock_link("tools_^_b_~_text", "text", "node_b", "orch")
link_c = _make_mock_link("tools_^_c_~_text", "text", "node_c", "orch")
mock_db = AsyncMock()
mock_db.get_connected_output_nodes.return_value = [
(link_a, node_a),
(link_b, node_b),
(link_c, node_c),
]
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)) == len(names), f"Tool names are not unique: {names}"
# The user-named tool keeps its name
assert f"{base}_1" in names
# The duplicates should skip _1 (taken) and use _2, _3
assert f"{base}_2" in names
assert f"{base}_3" in names