mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user