mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-01-09 08:08:32 +00:00
Fix hierarchical delegation language-agnostic tool matching
- Add language-agnostic tool name matching in ToolUsage._select_tool() - Support stable identifiers (delegate_work, ask_question) in addition to English names - Update delegation counting to recognize short identifiers - Update short-term memory filter to skip delegation actions with any alias - Add comprehensive unit tests for language-agnostic matching - Fixes issue #3925 where hierarchical process fails with non-English prompts Co-Authored-By: João <joao@crewai.com>
This commit is contained in:
@@ -35,23 +35,35 @@ class CrewAgentExecutorMixin:
|
|||||||
self.crew
|
self.crew
|
||||||
and self.agent
|
and self.agent
|
||||||
and self.task
|
and self.task
|
||||||
and "Action: Delegate work to coworker" not in output.text
|
|
||||||
):
|
):
|
||||||
try:
|
is_delegation = any(
|
||||||
if (
|
pattern in output.text
|
||||||
hasattr(self.crew, "_short_term_memory")
|
for pattern in [
|
||||||
and self.crew._short_term_memory
|
"Action: Delegate work to coworker",
|
||||||
):
|
"Action: delegate_work",
|
||||||
self.crew._short_term_memory.save(
|
"Action: delegate_work_to_coworker",
|
||||||
value=output.text,
|
"Action: Ask question to coworker",
|
||||||
metadata={
|
"Action: ask_question",
|
||||||
"observation": self.task.description,
|
"Action: ask_question_to_coworker",
|
||||||
},
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_delegation:
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
hasattr(self.crew, "_short_term_memory")
|
||||||
|
and self.crew._short_term_memory
|
||||||
|
):
|
||||||
|
self.crew._short_term_memory.save(
|
||||||
|
value=output.text,
|
||||||
|
metadata={
|
||||||
|
"observation": self.task.description,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.agent._logger.log(
|
||||||
|
"error", f"Failed to add to short term memory: {e}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
self.agent._logger.log(
|
|
||||||
"error", f"Failed to add to short term memory: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_external_memory(self, output) -> None:
|
def _create_external_memory(self, output) -> None:
|
||||||
"""Create and save a external-term memory item if conditions are met."""
|
"""Create and save a external-term memory item if conditions are met."""
|
||||||
|
|||||||
@@ -240,10 +240,18 @@ class ToolUsage:
|
|||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
try:
|
try:
|
||||||
if calling.tool_name in [
|
normalized_tool_name = self._normalize_tool_name(calling.tool_name)
|
||||||
|
is_delegation_tool = normalized_tool_name in [
|
||||||
|
"delegate_work_to_coworker",
|
||||||
|
"delegate_work",
|
||||||
|
"ask_question_to_coworker",
|
||||||
|
"ask_question",
|
||||||
|
] or calling.tool_name in [
|
||||||
"Delegate work to coworker",
|
"Delegate work to coworker",
|
||||||
"Ask question to coworker",
|
"Ask question to coworker",
|
||||||
]:
|
]
|
||||||
|
|
||||||
|
if is_delegation_tool:
|
||||||
coworker = (
|
coworker = (
|
||||||
calling.arguments.get("coworker") if calling.arguments else None
|
calling.arguments.get("coworker") if calling.arguments else None
|
||||||
)
|
)
|
||||||
@@ -400,7 +408,78 @@ class ToolUsage:
|
|||||||
return f"Tool '{tool_name}' has reached its usage limit of {tool.max_usage_count} times and cannot be used anymore."
|
return f"Tool '{tool_name}' has reached its usage limit of {tool.max_usage_count} times and cannot be used anymore."
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _normalize_tool_name(self, name: str) -> str:
|
||||||
|
"""Normalize tool name for language-agnostic matching.
|
||||||
|
|
||||||
|
Converts to lowercase, removes extra whitespace, and replaces
|
||||||
|
spaces/hyphens with underscores for consistent matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The tool name to normalize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized tool name
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
normalized = name.lower().strip()
|
||||||
|
normalized = normalized.replace(" ", "_").replace("-", "_")
|
||||||
|
while "__" in normalized:
|
||||||
|
normalized = normalized.replace("__", "_")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _get_tool_aliases(self, tool: Any) -> list[str]:
|
||||||
|
"""Get all possible aliases for a tool including stable identifiers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: The tool object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of possible tool name aliases
|
||||||
|
"""
|
||||||
|
aliases = [tool.name] # Original name
|
||||||
|
|
||||||
|
normalized = self._normalize_tool_name(tool.name)
|
||||||
|
if normalized and normalized != tool.name:
|
||||||
|
aliases.append(normalized)
|
||||||
|
|
||||||
|
if tool.name == "Delegate work to coworker":
|
||||||
|
aliases.extend(["delegate_work", "delegate_work_to_coworker"])
|
||||||
|
elif tool.name == "Ask question to coworker":
|
||||||
|
aliases.extend(["ask_question", "ask_question_to_coworker"])
|
||||||
|
|
||||||
|
return aliases
|
||||||
|
|
||||||
def _select_tool(self, tool_name: str) -> Any:
|
def _select_tool(self, tool_name: str) -> Any:
|
||||||
|
"""Select a tool by name with language-agnostic matching support.
|
||||||
|
|
||||||
|
Supports matching against:
|
||||||
|
1. Exact tool name (case-insensitive)
|
||||||
|
2. Normalized/slugified tool name
|
||||||
|
3. Stable short identifiers (delegate_work, ask_question)
|
||||||
|
4. Fuzzy matching as fallback (0.85 threshold)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: The name of the tool to select
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The selected tool object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If no matching tool is found
|
||||||
|
"""
|
||||||
|
normalized_input = self._normalize_tool_name(tool_name)
|
||||||
|
|
||||||
|
for tool in self.tools:
|
||||||
|
aliases = self._get_tool_aliases(tool)
|
||||||
|
|
||||||
|
if tool.name.lower().strip() == tool_name.lower().strip():
|
||||||
|
return tool
|
||||||
|
|
||||||
|
for alias in aliases:
|
||||||
|
if self._normalize_tool_name(alias) == normalized_input:
|
||||||
|
return tool
|
||||||
|
|
||||||
order_tools = sorted(
|
order_tools = sorted(
|
||||||
self.tools,
|
self.tools,
|
||||||
key=lambda tool: SequenceMatcher(
|
key=lambda tool: SequenceMatcher(
|
||||||
@@ -410,13 +489,13 @@ class ToolUsage:
|
|||||||
)
|
)
|
||||||
for tool in order_tools:
|
for tool in order_tools:
|
||||||
if (
|
if (
|
||||||
tool.name.lower().strip() == tool_name.lower().strip()
|
SequenceMatcher(
|
||||||
or SequenceMatcher(
|
|
||||||
None, tool.name.lower().strip(), tool_name.lower().strip()
|
None, tool.name.lower().strip(), tool_name.lower().strip()
|
||||||
).ratio()
|
).ratio()
|
||||||
> 0.85
|
> 0.85
|
||||||
):
|
):
|
||||||
return tool
|
return tool
|
||||||
|
|
||||||
if self.task:
|
if self.task:
|
||||||
self.task.increment_tools_errors()
|
self.task.increment_tools_errors()
|
||||||
tool_selection_data: dict[str, Any] = {
|
tool_selection_data: dict[str, Any] = {
|
||||||
|
|||||||
@@ -742,3 +742,206 @@ def test_tool_usage_finished_event_with_cached_result():
|
|||||||
assert isinstance(event.started_at, datetime.datetime)
|
assert isinstance(event.started_at, datetime.datetime)
|
||||||
assert isinstance(event.finished_at, datetime.datetime)
|
assert isinstance(event.finished_at, datetime.datetime)
|
||||||
assert event.type == "tool_usage_finished"
|
assert event.type == "tool_usage_finished"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_tool_name():
|
||||||
|
"""Test tool name normalization for language-agnostic matching."""
|
||||||
|
tool_usage = ToolUsage(
|
||||||
|
tools_handler=MagicMock(),
|
||||||
|
tools=[],
|
||||||
|
task=MagicMock(),
|
||||||
|
function_calling_llm=None,
|
||||||
|
agent=MagicMock(),
|
||||||
|
action=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tool_usage._normalize_tool_name("Delegate work to coworker") == "delegate_work_to_coworker"
|
||||||
|
assert tool_usage._normalize_tool_name("Ask question to coworker") == "ask_question_to_coworker"
|
||||||
|
assert tool_usage._normalize_tool_name("delegate_work") == "delegate_work"
|
||||||
|
assert tool_usage._normalize_tool_name("DELEGATE WORK") == "delegate_work"
|
||||||
|
assert tool_usage._normalize_tool_name("delegate-work") == "delegate_work"
|
||||||
|
assert tool_usage._normalize_tool_name(" delegate work ") == "delegate_work"
|
||||||
|
assert tool_usage._normalize_tool_name("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_tool_aliases_for_delegate_work():
|
||||||
|
"""Test that delegate work tool has correct aliases."""
|
||||||
|
from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool
|
||||||
|
|
||||||
|
delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock())
|
||||||
|
|
||||||
|
tool_usage = ToolUsage(
|
||||||
|
tools_handler=MagicMock(),
|
||||||
|
tools=[delegate_tool],
|
||||||
|
task=MagicMock(),
|
||||||
|
function_calling_llm=None,
|
||||||
|
agent=MagicMock(),
|
||||||
|
action=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
aliases = tool_usage._get_tool_aliases(delegate_tool)
|
||||||
|
|
||||||
|
assert "Delegate work to coworker" in aliases
|
||||||
|
assert "delegate_work" in aliases
|
||||||
|
assert "delegate_work_to_coworker" in aliases
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_tool_aliases_for_ask_question():
|
||||||
|
"""Test that ask question tool has correct aliases."""
|
||||||
|
from crewai.tools.agent_tools.ask_question_tool import AskQuestionTool
|
||||||
|
|
||||||
|
ask_tool = AskQuestionTool(agents=[], i18n=MagicMock())
|
||||||
|
|
||||||
|
tool_usage = ToolUsage(
|
||||||
|
tools_handler=MagicMock(),
|
||||||
|
tools=[ask_tool],
|
||||||
|
task=MagicMock(),
|
||||||
|
function_calling_llm=None,
|
||||||
|
agent=MagicMock(),
|
||||||
|
action=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
aliases = tool_usage._get_tool_aliases(ask_tool)
|
||||||
|
|
||||||
|
assert "Ask question to coworker" in aliases
|
||||||
|
assert "ask_question" in aliases
|
||||||
|
assert "ask_question_to_coworker" in aliases
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_tool_with_short_identifier():
|
||||||
|
"""Test tool selection using short identifiers like delegate_work."""
|
||||||
|
from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool
|
||||||
|
from crewai.tools.agent_tools.ask_question_tool import AskQuestionTool
|
||||||
|
|
||||||
|
delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock())
|
||||||
|
ask_tool = AskQuestionTool(agents=[], i18n=MagicMock())
|
||||||
|
|
||||||
|
tool_usage = ToolUsage(
|
||||||
|
tools_handler=MagicMock(),
|
||||||
|
tools=[delegate_tool, ask_tool],
|
||||||
|
task=MagicMock(),
|
||||||
|
function_calling_llm=None,
|
||||||
|
agent=MagicMock(),
|
||||||
|
action=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test short identifiers
|
||||||
|
selected = tool_usage._select_tool("delegate_work")
|
||||||
|
assert selected.name == "Delegate work to coworker"
|
||||||
|
|
||||||
|
selected = tool_usage._select_tool("ask_question")
|
||||||
|
assert selected.name == "Ask question to coworker"
|
||||||
|
|
||||||
|
# Test slugified versions
|
||||||
|
selected = tool_usage._select_tool("delegate_work_to_coworker")
|
||||||
|
assert selected.name == "Delegate work to coworker"
|
||||||
|
|
||||||
|
selected = tool_usage._select_tool("ask_question_to_coworker")
|
||||||
|
assert selected.name == "Ask question to coworker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_tool_with_exact_name():
|
||||||
|
"""Test tool selection with exact English name still works."""
|
||||||
|
from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool
|
||||||
|
|
||||||
|
delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock())
|
||||||
|
|
||||||
|
tool_usage = ToolUsage(
|
||||||
|
tools_handler=MagicMock(),
|
||||||
|
tools=[delegate_tool],
|
||||||
|
task=MagicMock(),
|
||||||
|
function_calling_llm=None,
|
||||||
|
agent=MagicMock(),
|
||||||
|
action=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test exact name matching (backward compatibility)
|
||||||
|
selected = tool_usage._select_tool("Delegate work to coworker")
|
||||||
|
assert selected.name == "Delegate work to coworker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_tool_case_insensitive():
|
||||||
|
"""Test tool selection is case-insensitive."""
|
||||||
|
from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool
|
||||||
|
|
||||||
|
delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock())
|
||||||
|
|
||||||
|
tool_usage = ToolUsage(
|
||||||
|
tools_handler=MagicMock(),
|
||||||
|
tools=[delegate_tool],
|
||||||
|
task=MagicMock(),
|
||||||
|
function_calling_llm=None,
|
||||||
|
agent=MagicMock(),
|
||||||
|
action=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test case variations
|
||||||
|
selected = tool_usage._select_tool("DELEGATE_WORK")
|
||||||
|
assert selected.name == "Delegate work to coworker"
|
||||||
|
|
||||||
|
selected = tool_usage._select_tool("Delegate_Work")
|
||||||
|
assert selected.name == "Delegate work to coworker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delegation_counting_with_short_identifiers():
|
||||||
|
"""Test that delegation counting works with short identifiers."""
|
||||||
|
from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool
|
||||||
|
from crewai.tools.tool_calling import ToolCalling
|
||||||
|
|
||||||
|
delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock())
|
||||||
|
mock_task = MagicMock()
|
||||||
|
mock_task.increment_delegations = MagicMock()
|
||||||
|
|
||||||
|
tool_usage = ToolUsage(
|
||||||
|
tools_handler=MagicMock(),
|
||||||
|
tools=[delegate_tool],
|
||||||
|
task=mock_task,
|
||||||
|
function_calling_llm=None,
|
||||||
|
agent=MagicMock(),
|
||||||
|
action=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a ToolCalling with short identifier
|
||||||
|
calling = ToolCalling(
|
||||||
|
tool_name="delegate_work",
|
||||||
|
arguments={"coworker": "researcher", "task": "test", "context": "test"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the tool invocation to avoid actual execution
|
||||||
|
with patch.object(delegate_tool, 'invoke', return_value="test result"):
|
||||||
|
tool_usage.use(calling, "test string")
|
||||||
|
|
||||||
|
# Verify delegation was counted
|
||||||
|
mock_task.increment_delegations.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_filter_with_short_identifiers():
|
||||||
|
"""Test that memory filter recognizes short identifiers."""
|
||||||
|
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
|
||||||
|
|
||||||
|
class TestMixin(CrewAgentExecutorMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.crew = MagicMock()
|
||||||
|
self.crew._short_term_memory = MagicMock()
|
||||||
|
self.agent = MagicMock()
|
||||||
|
self.agent._logger = MagicMock()
|
||||||
|
self.task = MagicMock()
|
||||||
|
self.task.description = "test task"
|
||||||
|
|
||||||
|
mixin = TestMixin()
|
||||||
|
|
||||||
|
# Test with short identifier - should NOT save to memory
|
||||||
|
output = MagicMock()
|
||||||
|
output.text = "Action: delegate_work\nAction Input: {...}"
|
||||||
|
mixin._create_short_term_memory(output)
|
||||||
|
mixin.crew._short_term_memory.save.assert_not_called()
|
||||||
|
|
||||||
|
# Test with English name - should NOT save to memory
|
||||||
|
output.text = "Action: Delegate work to coworker\nAction Input: {...}"
|
||||||
|
mixin._create_short_term_memory(output)
|
||||||
|
mixin.crew._short_term_memory.save.assert_not_called()
|
||||||
|
|
||||||
|
# Test with non-delegation action - should save to memory
|
||||||
|
output.text = "Action: Some other tool\nAction Input: {...}"
|
||||||
|
mixin._create_short_term_memory(output)
|
||||||
|
mixin.crew._short_term_memory.save.assert_called_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user