Compare commits

...

4 Commits

Author SHA1 Message Date
Devin AI
57f386d748 Fix test instantiation issues: use description parameter instead of i18n mock
Co-Authored-By: João <joao@crewai.com>
2025-11-16 04:23:00 +00:00
Devin AI
922165d110 Fix test bug: set cache return value to None for delegation counting test
Co-Authored-By: João <joao@crewai.com>
2025-11-16 04:11:36 +00:00
Devin AI
37b4252fa7 Fix lint errors: remove trailing whitespace from docstrings
Co-Authored-By: João <joao@crewai.com>
2025-11-16 04:07:19 +00:00
Devin AI
d5bc82be21 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>
2025-11-16 04:03:48 +00:00
3 changed files with 281 additions and 19 deletions

View File

@@ -35,23 +35,35 @@ class CrewAgentExecutorMixin:
self.crew
and self.agent
and self.task
and "Action: Delegate work to coworker" not in output.text
):
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,
},
is_delegation = any(
pattern in output.text
for pattern in [
"Action: Delegate work to coworker",
"Action: delegate_work",
"Action: delegate_work_to_coworker",
"Action: Ask question to coworker",
"Action: ask_question",
"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:
"""Create and save a external-term memory item if conditions are met."""

View File

@@ -240,10 +240,18 @@ class ToolUsage:
if result is None:
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",
"Ask question to coworker",
]:
]
if is_delegation_tool:
coworker = (
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 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:
"""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(
self.tools,
key=lambda tool: SequenceMatcher(
@@ -410,13 +489,13 @@ class ToolUsage:
)
for tool in order_tools:
if (
tool.name.lower().strip() == tool_name.lower().strip()
or SequenceMatcher(
SequenceMatcher(
None, tool.name.lower().strip(), tool_name.lower().strip()
).ratio()
> 0.85
):
return tool
if self.task:
self.task.increment_tools_errors()
tool_selection_data: dict[str, Any] = {

View File

@@ -742,3 +742,174 @@ def test_tool_usage_finished_event_with_cached_result():
assert isinstance(event.started_at, datetime.datetime)
assert isinstance(event.finished_at, datetime.datetime)
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=[], description="Test delegate tool")
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=[], description="Test ask question tool")
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=[], description="Test delegate tool")
ask_tool = AskQuestionTool(agents=[], description="Test ask question tool")
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=[], description="Test delegate tool")
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=[], description="Test delegate tool")
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_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()