Files
crewAI/lib/crewai/tests/new_agent/test_gap_audit4.py
Joao Moura fe7f730546 feat: add interactive agent creation and TUI for multi-agent interaction
- Introduced a new `create_agent` command for interactive agent definition.
- Added `agent_tui.py` for a conversational TUI supporting multi-agent interactions.
- Updated CLI to support agent creation and training workflows.
- Enhanced `.gitignore` to exclude demo files and configuration artifacts.
- Implemented a benchmark runner for testing agent performance against defined cases.

This commit lays the groundwork for a more interactive and user-friendly experience in managing agents within the CrewAI framework.
2026-05-13 12:28:25 -07:00

473 lines
17 KiB
Python

"""Tests for GAP-117 through GAP-121 (fourth audit pass)."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from crewai.new_agent.models import (
AgentSettings,
AgentStatus,
Message,
ProvenanceEntry,
TokenUsage,
)
# ── Helpers ────────────────────────────────────────────────────────
def _make_executor(
*,
provenance_detail: str = "standard",
memory_enabled: bool = True,
tools: list | None = None,
coworker_tools: list | None = None,
):
"""Build a lightweight mock executor for testing."""
from crewai.new_agent.executor import ConversationalAgentExecutor
agent = MagicMock()
agent.id = "test-agent-1"
agent.role = "Researcher"
agent.goal = "Research things"
agent.backstory = ""
agent.settings = AgentSettings(
provenance_detail=provenance_detail,
memory_enabled=memory_enabled,
)
agent.response_model = None
agent._llm_instance = MagicMock()
agent._llm_instance.model = "openai/gpt-4o"
agent._resolved_tools = tools or []
agent._coworker_tools = coworker_tools or []
agent._knowledge_discovery = None
agent.step_callback = None
agent.verbose = False
agent.knowledge = None
agent.knowledge_sources = []
executor = ConversationalAgentExecutor(agent=agent, provider=None)
return executor, agent
# ── GAP-117: Delegating status emission ───────────────────────────
class TestGAP117DelegatingStatus:
"""Executor should emit 'delegating' status for delegate_to_* tools."""
@pytest.mark.asyncio
async def test_delegation_tool_emits_delegating_status(self):
executor, agent = _make_executor()
statuses: list[AgentStatus] = []
provider = AsyncMock()
async def capture_status(status):
statuses.append(status)
provider.send_status = capture_status
provider.send_message = AsyncMock()
executor.provider = provider
# Simulate _emit_status being called for a delegation tool
await executor._emit_status(
"delegating", "Asking @writer…", coworker="writer"
)
assert len(statuses) == 1
assert statuses[0].state == "delegating"
assert statuses[0].coworker == "writer"
def test_delegate_tool_name_detected(self):
"""Tool names starting with 'delegate_to_' should be treated as delegations."""
assert "delegate_to_writer".startswith("delegate_to_")
assert "delegate_to_a2a_remote".startswith("delegate_to_")
assert not "search_web".startswith("delegate_to_")
def test_coworker_label_extraction(self):
"""The coworker label should be extracted from the tool name."""
func_name = "delegate_to_content_writer"
label = func_name.replace("delegate_to_", "").replace("_", " ")
assert label == "content writer"
# ── GAP-118: Token usage events emitted for billing ───────────────
class TestGAP118TokenUsageEvents:
"""Token usage should emit events for platform billing."""
def test_token_usage_event_class_exists(self):
from crewai.new_agent.events import NewAgentTokenUsageEvent
event = NewAgentTokenUsageEvent(
new_agent_id="a1",
conversation_id="c1",
action="message",
input_tokens=100,
output_tokens=50,
model="gpt-4o",
)
assert event.type == "new_agent_token_usage"
assert event.input_tokens == 100
assert event.output_tokens == 50
def test_record_token_usage_emits_event(self):
executor, agent = _make_executor()
executor._turn_input_tokens = 200
executor._turn_output_tokens = 100
executor.conversation_history = [
Message(role="user", content="hi", conversation_id="conv-1")
]
emitted = []
original_emit = executor._emit_event
def capture_event(event):
emitted.append(event)
try:
original_emit(event)
except Exception:
pass
executor._emit_event = capture_event
executor._record_token_usage("message", "gpt-4o")
from crewai.new_agent.events import NewAgentTokenUsageEvent
token_events = [e for e in emitted if isinstance(e, NewAgentTokenUsageEvent)]
assert len(token_events) == 1
assert token_events[0].action == "message"
assert token_events[0].input_tokens == 200
assert token_events[0].output_tokens == 100
assert token_events[0].conversation_id == "conv-1"
def test_record_token_usage_still_appends_record(self):
executor, agent = _make_executor()
executor._turn_input_tokens = 50
executor._turn_output_tokens = 25
executor._record_token_usage("tool_call", "gpt-4o", tool_name="search")
assert len(executor.usage_records) == 1
assert executor.usage_records[0].action == "tool_call"
assert executor.usage_records[0].tool_name == "search"
# ── GAP-119: Knowledge suggestions surfaced conversationally ──────
class TestGAP119KnowledgeSurfacing:
"""Knowledge suggestions should be sent as agent messages via provider."""
def test_knowledge_suggestion_sends_message(self):
executor, agent = _make_executor()
executor.conversation_history = [
Message(role="user", content="test", conversation_id="conv-1")
]
# Set up a mock provider
provider = MagicMock()
sent_messages: list[Message] = []
async def mock_send(msg):
sent_messages.append(msg)
provider.send_message = mock_send
executor.provider = provider
# Set up mock knowledge discovery
kd = MagicMock()
kd.evaluate_for_knowledge.return_value = {
"title": "search_web: AI agent frameworks comparison",
"content": "Some long content...",
"source_tool": "search_web",
"status": "pending",
}
agent._knowledge_discovery = kd
# The actual integration happens inside _execute_tool_calls
# Test the message construction via KnowledgeDiscovery.build_suggestion_message
suggestion = kd.evaluate_for_knowledge("search_web", "Some long content...")
from crewai.new_agent.knowledge_discovery import KnowledgeDiscovery
from crewai.new_agent.models import Message as AgentMessage, MessageAction
text, actions = KnowledgeDiscovery.build_suggestion_message(kd, suggestion)
action_objs = [MessageAction(**a) for a in actions]
hint_msg = AgentMessage(
role="agent",
content=text,
actions=action_objs,
sender="Researcher",
conversation_id="conv-1",
)
assert "AI agent frameworks comparison" in hint_msg.content
assert hint_msg.role == "agent"
assert "knowledge source" in hint_msg.content.lower() or "save" in hint_msg.content.lower()
assert hint_msg.actions is not None
assert len(hint_msg.actions) >= 2
def test_no_message_when_no_suggestion(self):
"""If evaluate_for_knowledge returns None, no message should be sent."""
executor, agent = _make_executor()
kd = MagicMock()
kd.evaluate_for_knowledge.return_value = None
agent._knowledge_discovery = kd
provider = MagicMock()
provider.send_message = AsyncMock()
executor.provider = provider
# Simulate the evaluation returning None
result = kd.evaluate_for_knowledge("search_web", "short")
assert result is None
# Provider should not have been called
provider.send_message.assert_not_called()
def test_no_message_when_no_provider(self):
"""If no provider is set, knowledge surfacing is silently skipped."""
executor, agent = _make_executor()
executor.provider = None
kd = MagicMock()
kd.evaluate_for_knowledge.return_value = {
"title": "test", "content": "...", "source_tool": "search", "status": "pending"
}
agent._knowledge_discovery = kd
# Should not raise even without provider
suggestion = kd.evaluate_for_knowledge("search", "long content " * 50)
assert suggestion is not None
# ── GAP-120: Memory scope filtering ──────────────────────────────
class TestGAP120MemoryScopeFiltering:
"""Memory recall should filter by conversation and user scope."""
def test_filters_out_other_conversation_memories(self):
executor, agent = _make_executor()
executor.conversation_history = [
Message(role="user", content="hi", conversation_id="conv-A")
]
# Create mock memories with different conversation scopes
m1 = MagicMock()
m1.content = "Global fact"
m1.metadata = {}
m2 = MagicMock()
m2.content = "Conv-A memory"
m2.metadata = {"conversation_id": "conv-A"}
m3 = MagicMock()
m3.content = "Conv-B memory (should be filtered)"
m3.metadata = {"conversation_id": "conv-B"}
memory = MagicMock()
memory.recall.return_value = [m1, m2, m3]
agent._memory_instance = memory
result = executor._recall_memory("query")
assert "Global fact" in result
assert "Conv-A memory" in result
assert "Conv-B" not in result
def test_filters_out_other_user_memories(self):
executor, agent = _make_executor()
executor.conversation_history = [
Message(role="user", content="hi", conversation_id="conv-1")
]
provider = MagicMock()
provider.user_id = "user-alice"
executor.provider = provider
m1 = MagicMock()
m1.content = "Alice's preference"
m1.metadata = {"user_id": "user-alice"}
m2 = MagicMock()
m2.content = "Bob's preference (should be filtered)"
m2.metadata = {"user_id": "user-bob"}
m3 = MagicMock()
m3.content = "Unscoped memory"
m3.metadata = {}
memory = MagicMock()
memory.recall.return_value = [m1, m2, m3]
agent._memory_instance = memory
result = executor._recall_memory("query")
assert "Alice's preference" in result
assert "Bob's preference" not in result
assert "Unscoped memory" in result
def test_no_filter_when_no_scope_metadata(self):
executor, agent = _make_executor()
executor.conversation_history = [
Message(role="user", content="hi", conversation_id="conv-1")
]
m1 = MagicMock()
m1.content = "Memory without metadata"
m1.metadata = {}
memory = MagicMock()
memory.recall.return_value = [m1]
agent._memory_instance = memory
result = executor._recall_memory("query")
assert "Memory without metadata" in result
def test_no_filter_when_no_provider_user(self):
"""When provider has no user_id, user-scoped memories pass through."""
executor, agent = _make_executor()
executor.conversation_history = [
Message(role="user", content="hi", conversation_id="conv-1")
]
executor.provider = None # No provider
m1 = MagicMock()
m1.content = "User-scoped but no provider to check against"
m1.metadata = {"user_id": "user-alice"}
memory = MagicMock()
memory.recall.return_value = [m1]
agent._memory_instance = memory
result = executor._recall_memory("query")
# Should pass through since we can't verify user
assert "User-scoped" in result
def test_string_metadata_handled_gracefully(self):
"""If metadata is a string instead of dict, don't crash."""
executor, agent = _make_executor()
executor.conversation_history = [
Message(role="user", content="hi", conversation_id="conv-1")
]
m1 = MagicMock()
m1.content = "Memory with bad metadata"
m1.metadata = "not a dict"
memory = MagicMock()
memory.recall.return_value = [m1]
agent._memory_instance = memory
result = executor._recall_memory("query")
assert "Memory with bad metadata" in result
def test_empty_results_after_filtering(self):
"""If all memories are filtered out, return empty string."""
executor, agent = _make_executor()
executor.conversation_history = [
Message(role="user", content="hi", conversation_id="conv-A")
]
m1 = MagicMock()
m1.content = "Wrong conversation"
m1.metadata = {"conversation_id": "conv-B"}
memory = MagicMock()
memory.recall.return_value = [m1]
agent._memory_instance = memory
result = executor._recall_memory("query")
assert result == ""
# ── GAP-121: Standard provenance tier reasoning extraction ────────
class TestGAP121StandardProvenance:
"""Standard tier should extract reasoning from model response text."""
def test_extract_reasoning_explicit_marker(self):
from crewai.new_agent.executor import ConversationalAgentExecutor
text = "Here is the analysis. My reasoning is: the data shows a clear trend toward AI adoption. Therefore I recommend investing."
result = ConversationalAgentExecutor._extract_reasoning_from_text(text)
assert "data shows" in result or "clear trend" in result
def test_extract_reasoning_because_pattern(self):
from crewai.new_agent.executor import ConversationalAgentExecutor
text = "Because the API rate limits are strict, I chose to batch the requests in groups of 10."
result = ConversationalAgentExecutor._extract_reasoning_from_text(text)
assert len(result) > 15
def test_extract_reasoning_decided_pattern(self):
from crewai.new_agent.executor import ConversationalAgentExecutor
text = "I decided to use Python for this task because it has the best library support for data analysis."
result = ConversationalAgentExecutor._extract_reasoning_from_text(text)
assert len(result) > 15
def test_extract_reasoning_fallback_first_sentence(self):
from crewai.new_agent.executor import ConversationalAgentExecutor
text = "The quarterly revenue exceeded expectations by 15 percent. This is good news for investors."
result = ConversationalAgentExecutor._extract_reasoning_from_text(text)
assert "quarterly revenue" in result
def test_extract_reasoning_empty_text(self):
from crewai.new_agent.executor import ConversationalAgentExecutor
assert ConversationalAgentExecutor._extract_reasoning_from_text("") == ""
def test_extract_reasoning_short_text(self):
from crewai.new_agent.executor import ConversationalAgentExecutor
result = ConversationalAgentExecutor._extract_reasoning_from_text("ok")
assert result == ""
def test_standard_different_from_minimal(self):
"""Standard tier should produce reasoning; minimal should not."""
from crewai.new_agent.executor import ConversationalAgentExecutor
response_text = "I decided to search the web because the user needs current information about AI frameworks."
# Standard: should extract reasoning
standard_result = ConversationalAgentExecutor._extract_reasoning_from_text(
response_text
)
assert len(standard_result) > 0
@pytest.mark.asyncio
async def test_maybe_generate_reasoning_minimal_returns_empty(self):
executor, _ = _make_executor(provenance_detail="minimal")
result = await executor._maybe_generate_reasoning(
"response", {"msg": "test"}, "Some outcome text here with reasoning."
)
assert result == ""
@pytest.mark.asyncio
async def test_maybe_generate_reasoning_standard_extracts(self):
executor, _ = _make_executor(provenance_detail="standard")
result = await executor._maybe_generate_reasoning(
"response",
{"msg": "test"},
"Because the user asked about recent trends, I searched for the latest publications.",
)
assert len(result) > 0
def test_reasoning_truncated_at_300_chars(self):
from crewai.new_agent.executor import ConversationalAgentExecutor
long_text = "My reasoning is: " + "a" * 500
result = ConversationalAgentExecutor._extract_reasoning_from_text(long_text)
assert len(result) <= 300