Gl/feat/a2a refactor (#3793)

* feat: agent metaclass, refactor a2a to wrappers

* feat: a2a schemas and utils

* chore: move agent class, update imports

* refactor: organize imports to avoid circularity, add a2a to console

* feat: pass response_model through call chain

* feat: add standard openapi spec serialization to tools and structured output

* feat: a2a events

* chore: add a2a to pyproject

* docs: minimal base for learn docs

* fix: adjust a2a conversation flow, allow llm to decide exit until max_retries

* fix: inject agent skills into initial prompt

* fix: format agent card as json in prompt

* refactor: simplify A2A agent prompt formatting and improve skill display

* chore: wide cleanup

* chore: cleanup logic, add auth cache, use json for messages in prompt

* chore: update docs

* fix: doc snippets formatting

* feat: optimize A2A agent card fetching and improve error reporting

* chore: move imports to top of file

* chore: refactor hasattr check

* chore: add httpx-auth, update lockfile

* feat: create base public api

* chore: cleanup modules, add docstrings, types

* fix: exclude extra fields in prompt

* chore: update docs

* tests: update to correct import

* chore: lint for ruff, add missing import

* fix: tweak openai streaming logic for response model

* tests: add reimport for test

* tests: add reimport for test

* fix: don't set a2a attr if not set

* fix: don't set a2a attr if not set

* chore: update cassettes

* tests: fix tests

* fix: use instructor and dont pass response_format for litellm

* chore: consolidate event listeners, add typing

* fix: address race condition in test, update cassettes

* tests: add correct mocks, rerun cassette for json

* tests: update cassette

* chore: regenerate cassette after new run

* fix: make token manager access-safe

* fix: make token manager access-safe

* merge

* chore: update test and cassete for output pydantic

* fix: tweak to disallow deadlock

* chore: linter

* fix: adjust event ordering for threading

* fix: use conditional for batch check

* tests: tweak for emission

* tests: simplify api + event check

* fix: ensure non-function calling llms see json formatted string

* tests: tweak message comparison

* fix: use internal instructor for litellm structure responses

---------

Co-authored-by: Mike Plachta <mike@crewai.com>
This commit is contained in:
Greyson LaLonde
2025-11-01 02:42:03 +01:00
committed by GitHub
parent e229ef4e19
commit e134e5305b
71 changed files with 9790 additions and 4592 deletions

View File

@@ -57,6 +57,7 @@ class TestTraceListenerSetup:
if hasattr(TraceCollectionListener, "_instance"):
TraceCollectionListener._instance = None
TraceCollectionListener._initialized = False
TraceCollectionListener._listeners_setup = False
# Reset EventListener singleton
if hasattr(EventListener, "_instance"):
@@ -74,6 +75,7 @@ class TestTraceListenerSetup:
if hasattr(TraceCollectionListener, "_instance"):
TraceCollectionListener._instance = None
TraceCollectionListener._initialized = False
TraceCollectionListener._listeners_setup = False
if hasattr(EventListener, "_instance"):
EventListener._instance = None
@@ -131,25 +133,16 @@ class TestTraceListenerSetup:
)
crew = Crew(agents=[agent], tasks=[task], verbose=True)
from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener
trace_listener = TraceCollectionListener()
from crewai.events.event_bus import crewai_event_bus
trace_listener.setup_listeners(crewai_event_bus)
crew.kickoff()
with patch.object(
trace_listener.batch_manager,
"initialize_batch",
return_value=None,
) as initialize_mock:
crew.kickoff()
initialized = trace_listener.batch_manager.wait_for_batch_initialization(timeout=5.0)
assert initialize_mock.call_count >= 1
call_args = initialize_mock.call_args_list[0]
assert len(call_args[0]) == 2 # user_context, execution_metadata
_, execution_metadata = call_args[0]
assert isinstance(execution_metadata, dict)
assert "crew_name" in execution_metadata
assert initialized, "Batch should have been initialized"
assert trace_listener.batch_manager.is_batch_initialized()
assert trace_listener.batch_manager.current_batch is not None
@pytest.mark.vcr(filter_headers=["authorization"])
def test_batch_manager_finalizes_batch_clears_buffer(self):
@@ -364,24 +357,21 @@ class TestTraceListenerSetup:
)
crew = Crew(agents=[agent], tasks=[task], tracing=True)
with patch.object(TraceBatchManager, "initialize_batch") as mock_initialize:
crew.kickoff()
from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener
trace_listener = TraceCollectionListener()
assert mock_initialize.call_count >= 1
assert mock_initialize.call_args_list[0][1]["use_ephemeral"] is True
crew.kickoff()
wait_for_event_handlers()
assert trace_listener.batch_manager.is_batch_initialized(), (
"Batch should have been initialized for unauthenticated user"
)
@pytest.mark.vcr(filter_headers=["authorization"])
def test_trace_listener_with_authenticated_user(self):
"""Test that trace listener properly handles authenticated batches"""
with (
patch.dict(os.environ, {"CREWAI_TRACING_ENABLED": "true"}),
patch(
"crewai.events.listeners.tracing.trace_batch_manager.PlusAPI"
) as mock_plus_api_class,
):
mock_plus_api_instance = MagicMock()
mock_plus_api_class.return_value = mock_plus_api_instance
with patch.dict(os.environ, {"CREWAI_TRACING_ENABLED": "true"}):
agent = Agent(
role="Test Agent",
goal="Test goal",
@@ -394,21 +384,17 @@ class TestTraceListenerSetup:
agent=agent,
)
with (
patch.object(TraceBatchManager, "initialize_batch") as mock_initialize,
patch.object(
TraceBatchManager, "finalize_batch"
) as mock_finalize_backend_batch,
):
crew = Crew(agents=[agent], tasks=[task], tracing=True)
crew.kickoff()
wait_for_event_handlers()
from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener
trace_listener = TraceCollectionListener()
mock_plus_api_class.assert_called_with(api_key="mock_token_12345")
crew = Crew(agents=[agent], tasks=[task], tracing=True)
crew.kickoff()
assert mock_initialize.call_count >= 1
mock_finalize_backend_batch.assert_called_with()
assert mock_finalize_backend_batch.call_count >= 1
wait_for_event_handlers()
assert trace_listener.batch_manager.is_batch_initialized(), (
"Batch should have been initialized for authenticated user"
)
# Helper method to ensure cleanup
def teardown_method(self):
@@ -489,30 +475,19 @@ class TestTraceListenerSetup:
assert trace_listener.first_time_handler.is_first_time is True
assert trace_listener.first_time_handler.collected_events is False
with (
patch.object(
trace_listener.first_time_handler,
"handle_execution_completion",
wraps=trace_listener.first_time_handler.handle_execution_completion,
) as mock_handle_completion,
patch.object(
trace_listener.batch_manager,
"add_event",
wraps=trace_listener.batch_manager.add_event,
) as mock_add_event,
):
result = crew.kickoff()
wait_for_event_handlers()
assert result is not None
trace_listener.batch_manager.batch_owner_type = "crew"
assert mock_handle_completion.call_count >= 1
assert mock_add_event.call_count >= 1
result = crew.kickoff()
wait_for_event_handlers()
assert result is not None
assert trace_listener.first_time_handler.collected_events is True
assert trace_listener.first_time_handler.collected_events is True, (
"Events should have been collected"
)
mock_prompt.assert_called_once()
mock_prompt.assert_called_once()
mock_mark_completed.assert_called_once()
mock_mark_completed.assert_called_once()
@pytest.mark.vcr(filter_headers=["authorization"])
def test_first_time_user_trace_collection_user_accepts(self, mock_plus_api_calls):
@@ -556,9 +531,10 @@ class TestTraceListenerSetup:
from crewai.events.event_bus import crewai_event_bus
trace_listener = TraceCollectionListener()
trace_listener.setup_listeners(crewai_event_bus)
assert trace_listener.first_time_handler.is_first_time is True
trace_listener.batch_manager.ephemeral_trace_url = (
"https://crewai.com/trace/mock-id"
)
with (
patch.object(
@@ -569,26 +545,17 @@ class TestTraceListenerSetup:
patch.object(
trace_listener.first_time_handler, "_display_ephemeral_trace_link"
) as mock_display_link,
patch.object(
trace_listener.first_time_handler,
"handle_execution_completion",
wraps=trace_listener.first_time_handler.handle_execution_completion,
) as mock_handle_completion,
):
trace_listener.batch_manager.ephemeral_trace_url = (
"https://crewai.com/trace/mock-id"
)
trace_listener.setup_listeners(crewai_event_bus)
assert trace_listener.first_time_handler.is_first_time is True
trace_listener.first_time_handler.collected_events = True
crew.kickoff()
wait_for_event_handlers()
assert mock_handle_completion.call_count >= 1, (
"handle_execution_completion should be called"
)
assert trace_listener.first_time_handler.collected_events is True, (
"Events should be marked as collected"
)
trace_listener.first_time_handler.handle_execution_completion()
mock_init_backend.assert_called_once()
@@ -636,15 +603,14 @@ class TestTraceListenerSetup:
)
crew = Crew(agents=[agent], tasks=[task])
with patch.object(TraceBatchManager, "initialize_batch") as mock_initialize:
result = crew.kickoff()
result = crew.kickoff()
assert trace_listener.batch_manager.wait_for_pending_events(timeout=5.0), (
"Timeout waiting for trace event handlers to complete"
)
assert mock_initialize.call_count >= 1
assert mock_initialize.call_args_list[0][1]["use_ephemeral"] is True
assert result is not None
wait_for_event_handlers()
assert trace_listener.batch_manager.is_batch_initialized(), (
"Batch should have been initialized for first-time user"
)
assert result is not None
def test_first_time_handler_timeout_behavior(self):
"""Test the timeout behavior of the first-time trace prompt"""
@@ -699,60 +665,43 @@ class TestTraceListenerSetup:
mock_mark_completed.assert_called_once()
@pytest.mark.vcr(filter_headers=["authorization"])
def test_trace_batch_marked_as_failed_on_finalize_error(self, mock_plus_api_calls):
def test_trace_batch_marked_as_failed_on_finalize_error(self):
"""Test that trace batch is marked as failed when finalization returns non-200 status"""
# Test the error handling logic directly in TraceBatchManager
batch_manager = TraceBatchManager()
with patch.dict(os.environ, {"CREWAI_TRACING_ENABLED": "true"}):
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
llm="gpt-4o-mini",
# Initialize a batch
batch_manager.current_batch = batch_manager.initialize_batch(
user_context={"privacy_level": "standard"},
execution_metadata={
"execution_type": "crew",
"crew_name": "test_crew",
},
)
batch_manager.trace_batch_id = "test_batch_id_12345"
batch_manager.backend_initialized = True
# Mock the API responses
with (
patch.object(
batch_manager.plus_api,
"send_trace_events",
return_value=MagicMock(status_code=200),
),
patch.object(
batch_manager.plus_api,
"finalize_trace_batch",
return_value=MagicMock(status_code=500, text="Internal Server Error"),
),
patch.object(
batch_manager.plus_api,
"mark_trace_batch_as_failed",
) as mock_mark_failed,
):
# Call finalize_batch directly
batch_manager.finalize_batch()
# Verify that mark_trace_batch_as_failed was called with the error message
mock_mark_failed.assert_called_once_with(
"test_batch_id_12345", "Internal Server Error"
)
task = Task(
description="Say hello to the world",
expected_output="hello world",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task], verbose=True)
trace_listener = TraceCollectionListener()
from crewai.events.event_bus import crewai_event_bus
trace_listener.setup_listeners(crewai_event_bus)
mock_init_response = MagicMock()
mock_init_response.status_code = 200
mock_init_response.json.return_value = {"trace_id": "test_batch_id_12345"}
with (
patch.object(
trace_listener.batch_manager.plus_api,
"initialize_trace_batch",
return_value=mock_init_response,
),
patch.object(
trace_listener.batch_manager.plus_api,
"send_trace_events",
return_value=MagicMock(status_code=200),
),
patch.object(
trace_listener.batch_manager.plus_api,
"finalize_trace_batch",
return_value=MagicMock(
status_code=500, text="Internal Server Error"
),
),
patch.object(
trace_listener.batch_manager.plus_api,
"mark_trace_batch_as_failed",
wraps=mock_plus_api_calls["mark_trace_batch_as_failed"],
) as mock_mark_failed,
):
crew.kickoff()
wait_for_event_handlers()
mock_mark_failed.assert_called_once()
call_args = mock_mark_failed.call_args_list[0]
assert call_args[0][1] == "Internal Server Error"