Add compact mode and context management features to address issue #3912

- Add 5 new Agent configuration fields:
  - compact_mode: Enable compact prompt mode to reduce context size
  - tools_prompt_strategy: Choose between 'full' or 'names_only' for tools
  - proactive_context_trimming: Enable proactive message trimming
  - memory_max_chars: Cap memory context length
  - knowledge_max_chars: Cap knowledge context length

- Implement compact prompt mode in utilities/prompts.py
  - Caps role to 100 chars, goal to 150 chars
  - Omits backstory entirely in compact mode

- Implement tools_prompt_strategy in Agent.create_agent_executor
  - 'names_only' uses get_tool_names for minimal tool descriptions
  - 'full' uses render_text_description_and_args (default)

- Implement memory/knowledge size bounds in Agent.execute_task
  - Truncates memory and knowledge contexts when limits are set

- Add trim_messages_structurally helper in agent_utils.py
  - Structural trimming without LLM calls
  - Keeps system messages and last N message pairs

- Integrate proactive trimming in CrewAgentExecutor._invoke_loop
  - Trims messages before each LLM call when enabled

- Update LangGraphAdapter and OpenAIAdapter to honor compact_mode
  - Compacts role/goal/backstory in system prompts

- Add comprehensive tests for all new features:
  - test_prompts_compact_mode.py
  - test_memory_knowledge_truncation.py
  - test_tools_prompt_strategy.py
  - test_proactive_context_trimming.py

All changes are opt-in with conservative defaults to maintain backward compatibility.

Fixes #3912

Co-Authored-By: João <joao@crewai.com>
This commit is contained in:
Devin AI
2025-11-14 06:57:47 +00:00
parent d7bdac12a2
commit 6ae74f0ad9
11 changed files with 4787 additions and 4046 deletions

View File

@@ -0,0 +1,114 @@
"""Tests for memory and knowledge truncation."""
from unittest.mock import Mock, patch
import pytest
def test_truncate_text_helper():
"""Test basic text truncation helper logic."""
text = "A" * 1000
max_chars = 500
if len(text) > max_chars:
truncated = text[:max_chars] + "..."
assert len(truncated) == max_chars + 3
assert truncated.endswith("...")
assert truncated.startswith("A" * 100)
def test_memory_truncation_when_max_chars_set():
"""Test that memory is truncated when memory_max_chars is set."""
from crewai.agent import Agent
long_memory = "M" * 2000
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
memory_max_chars=1000,
)
if agent.memory_max_chars and len(long_memory) > agent.memory_max_chars:
truncated_memory = long_memory[:agent.memory_max_chars] + "..."
assert len(truncated_memory) == 1003
assert truncated_memory.endswith("...")
def test_memory_not_truncated_when_max_chars_none():
"""Test that memory is not truncated when memory_max_chars is None."""
from crewai.agent import Agent
long_memory = "M" * 2000
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
memory_max_chars=None,
)
result_memory = long_memory
if agent.memory_max_chars and len(long_memory) > agent.memory_max_chars:
result_memory = long_memory[:agent.memory_max_chars] + "..."
assert len(result_memory) == 2000
assert not result_memory.endswith("...")
def test_knowledge_truncation_when_max_chars_set():
"""Test that knowledge is truncated when knowledge_max_chars is set."""
from crewai.agent import Agent
long_knowledge = "K" * 3000
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
knowledge_max_chars=1500,
)
if agent.knowledge_max_chars and len(long_knowledge) > agent.knowledge_max_chars:
truncated_knowledge = long_knowledge[:agent.knowledge_max_chars] + "..."
assert len(truncated_knowledge) == 1503
assert truncated_knowledge.endswith("...")
def test_knowledge_not_truncated_when_max_chars_none():
"""Test that knowledge is not truncated when knowledge_max_chars is None."""
from crewai.agent import Agent
long_knowledge = "K" * 3000
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
knowledge_max_chars=None,
)
result_knowledge = long_knowledge
if agent.knowledge_max_chars and len(long_knowledge) > agent.knowledge_max_chars:
result_knowledge = long_knowledge[:agent.knowledge_max_chars] + "..."
assert len(result_knowledge) == 3000
assert not result_knowledge.endswith("...")
def test_agent_config_fields_exist():
"""Test that new configuration fields exist on Agent."""
from crewai.agent import Agent
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
memory_max_chars=1000,
knowledge_max_chars=2000,
)
assert hasattr(agent, "memory_max_chars")
assert hasattr(agent, "knowledge_max_chars")
assert agent.memory_max_chars == 1000
assert agent.knowledge_max_chars == 2000

View File

@@ -0,0 +1,134 @@
"""Tests for proactive context trimming."""
import pytest
from crewai.utilities.agent_utils import trim_messages_structurally
def test_trim_messages_structurally_keeps_system_message():
"""Test that trim_messages_structurally preserves system messages."""
messages = [
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
{"role": "user", "content": "How are you?"},
{"role": "assistant", "content": "I'm doing well"},
]
trim_messages_structurally(messages, keep_last_n=1, max_total_chars=100)
system_messages = [msg for msg in messages if msg.get("role") == "system"]
assert len(system_messages) == 1
assert system_messages[0]["content"] == "You are a helpful assistant"
def test_trim_messages_structurally_keeps_last_n_pairs():
"""Test that trim_messages_structurally keeps last N message pairs."""
messages = [
{"role": "system", "content": "System"},
{"role": "user", "content": "A" * 10000},
{"role": "assistant", "content": "B" * 10000},
{"role": "user", "content": "C" * 10000},
{"role": "assistant", "content": "D" * 10000},
{"role": "user", "content": "E" * 100},
{"role": "assistant", "content": "F" * 100},
]
trim_messages_structurally(messages, keep_last_n=1, max_total_chars=1000)
assert len(messages) == 3
assert messages[0]["role"] == "system"
assert messages[1]["content"] == "E" * 100
assert messages[2]["content"] == "F" * 100
def test_trim_messages_structurally_no_trim_when_under_limit():
"""Test that trim_messages_structurally doesn't trim when under limit."""
messages = [
{"role": "system", "content": "System"},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
]
original_length = len(messages)
trim_messages_structurally(messages, keep_last_n=3, max_total_chars=50000)
assert len(messages) == original_length
def test_trim_messages_structurally_handles_empty_messages():
"""Test that trim_messages_structurally handles empty message list."""
messages = []
trim_messages_structurally(messages, keep_last_n=3, max_total_chars=1000)
assert len(messages) == 0
def test_trim_messages_structurally_with_multiple_system_messages():
"""Test that trim_messages_structurally preserves all system messages."""
messages = [
{"role": "system", "content": "System 1"},
{"role": "system", "content": "System 2"},
{"role": "user", "content": "A" * 10000},
{"role": "assistant", "content": "B" * 10000},
{"role": "user", "content": "C" * 100},
{"role": "assistant", "content": "D" * 100},
]
trim_messages_structurally(messages, keep_last_n=1, max_total_chars=1000)
system_messages = [msg for msg in messages if msg.get("role") == "system"]
assert len(system_messages) == 2
def test_agent_proactive_context_trimming_config():
"""Test that Agent has proactive_context_trimming configuration field."""
from crewai.agent import Agent
agent_with_trimming = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
proactive_context_trimming=True,
)
agent_without_trimming = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
proactive_context_trimming=False,
)
assert hasattr(agent_with_trimming, "proactive_context_trimming")
assert hasattr(agent_without_trimming, "proactive_context_trimming")
assert agent_with_trimming.proactive_context_trimming is True
assert agent_without_trimming.proactive_context_trimming is False
def test_proactive_context_trimming_default_is_false():
"""Test that proactive_context_trimming defaults to False."""
from crewai.agent import Agent
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
)
assert agent.proactive_context_trimming is False
def test_trim_messages_structurally_calculates_total_chars_correctly():
"""Test that trim_messages_structurally calculates total characters correctly."""
messages = [
{"role": "system", "content": "12345"},
{"role": "user", "content": "67890"},
{"role": "assistant", "content": "ABCDE"},
]
total_chars = sum(len(str(msg.get("content", ""))) for msg in messages)
assert total_chars == 15
trim_messages_structurally(messages, keep_last_n=3, max_total_chars=20)
assert len(messages) == 3

View File

@@ -0,0 +1,85 @@
"""Tests for compact mode in prompt generation."""
from unittest.mock import Mock
import pytest
from crewai.utilities.prompts import Prompts
def test_prompts_compact_mode_shortens_role():
"""Test that compact mode caps role length to 100 characters."""
agent = Mock()
agent.role = "A" * 200
agent.goal = "Test goal"
agent.backstory = "Test backstory"
agent.compact_mode = True
prompts = Prompts(agent=agent, has_tools=False)
result = prompts._build_prompt(["role_playing"])
assert len(agent.role) == 200
assert "A" * 97 + "..." in result
assert "A" * 100 not in result
def test_prompts_compact_mode_shortens_goal():
"""Test that compact mode caps goal length to 150 characters."""
agent = Mock()
agent.role = "Test role"
agent.goal = "B" * 200
agent.backstory = "Test backstory"
agent.compact_mode = True
prompts = Prompts(agent=agent, has_tools=False)
result = prompts._build_prompt(["role_playing"])
assert len(agent.goal) == 200
assert "B" * 147 + "..." in result
assert "B" * 150 not in result
def test_prompts_compact_mode_omits_backstory():
"""Test that compact mode omits backstory entirely."""
agent = Mock()
agent.role = "Test role"
agent.goal = "Test goal"
agent.backstory = "This is a very long backstory that should be omitted in compact mode"
agent.compact_mode = True
prompts = Prompts(agent=agent, has_tools=False)
result = prompts._build_prompt(["role_playing"])
assert "backstory" not in result.lower() or result.count("{backstory}") > 0
def test_prompts_normal_mode_preserves_full_content():
"""Test that normal mode (compact_mode=False) preserves full role, goal, and backstory."""
agent = Mock()
agent.role = "A" * 200
agent.goal = "B" * 200
agent.backstory = "C" * 200
agent.compact_mode = False
prompts = Prompts(agent=agent, has_tools=False)
result = prompts._build_prompt(["role_playing"])
assert "A" * 200 in result
assert "B" * 200 in result
assert "C" * 200 in result
def test_prompts_compact_mode_default_false():
"""Test that compact mode defaults to False when not set."""
agent = Mock()
agent.role = "A" * 200
agent.goal = "B" * 200
agent.backstory = "C" * 200
del agent.compact_mode
prompts = Prompts(agent=agent, has_tools=False)
result = prompts._build_prompt(["role_playing"])
assert "A" * 200 in result
assert "B" * 200 in result
assert "C" * 200 in result

View File

@@ -0,0 +1,95 @@
"""Tests for tools_prompt_strategy configuration."""
from unittest.mock import Mock
import pytest
from crewai.utilities.agent_utils import get_tool_names, render_text_description_and_args
def test_get_tool_names_returns_comma_separated_names():
"""Test that get_tool_names returns comma-separated tool names."""
tool1 = Mock()
tool1.name = "search_tool"
tool2 = Mock()
tool2.name = "calculator_tool"
tool3 = Mock()
tool3.name = "file_reader_tool"
tools = [tool1, tool2, tool3]
result = get_tool_names(tools)
assert result == "search_tool, calculator_tool, file_reader_tool"
assert "description" not in result.lower()
def test_render_text_description_includes_descriptions():
"""Test that render_text_description_and_args includes full descriptions."""
tool1 = Mock()
tool1.description = "This is a search tool that searches the web for information"
tool2 = Mock()
tool2.description = "This is a calculator tool that performs mathematical operations"
tools = [tool1, tool2]
result = render_text_description_and_args(tools)
assert "search tool" in result
assert "calculator tool" in result
assert "searches the web" in result
assert "mathematical operations" in result
def test_names_only_strategy_is_shorter_than_full():
"""Test that names_only strategy produces shorter output than full descriptions."""
tool1 = Mock()
tool1.name = "search_tool"
tool1.description = "This is a very long description " * 10
tool2 = Mock()
tool2.name = "calculator_tool"
tool2.description = "This is another very long description " * 10
tools = [tool1, tool2]
names_only = get_tool_names(tools)
full_description = render_text_description_and_args(tools)
assert len(names_only) < len(full_description)
assert len(names_only) < 100
assert len(full_description) > 200
def test_agent_tools_prompt_strategy_config():
"""Test that Agent has tools_prompt_strategy configuration field."""
from crewai.agent import Agent
agent_full = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
tools_prompt_strategy="full",
)
agent_names = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
tools_prompt_strategy="names_only",
)
assert hasattr(agent_full, "tools_prompt_strategy")
assert hasattr(agent_names, "tools_prompt_strategy")
assert agent_full.tools_prompt_strategy == "full"
assert agent_names.tools_prompt_strategy == "names_only"
def test_tools_prompt_strategy_default_is_full():
"""Test that tools_prompt_strategy defaults to 'full'."""
from crewai.agent import Agent
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
)
assert agent.tools_prompt_strategy == "full"