From 2d09e6bbcd906790f8faee167237c0f227031f52 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 18:46:41 -0500 Subject: [PATCH 01/34] feat: add update mechanism config structure --- lib/crewai/src/crewai/a2a/updates/__init__.py | 15 +++++++++++ .../crewai/a2a/updates/polling/__init__.py | 1 + .../src/crewai/a2a/updates/polling/config.py | 23 +++++++++++++++++ .../updates/push_notifications/__init__.py | 1 + .../a2a/updates/push_notifications/config.py | 25 +++++++++++++++++++ .../crewai/a2a/updates/streaming/__init__.py | 1 + .../crewai/a2a/updates/streaming/config.py | 9 +++++++ 7 files changed, 75 insertions(+) create mode 100644 lib/crewai/src/crewai/a2a/updates/__init__.py create mode 100644 lib/crewai/src/crewai/a2a/updates/polling/__init__.py create mode 100644 lib/crewai/src/crewai/a2a/updates/polling/config.py create mode 100644 lib/crewai/src/crewai/a2a/updates/push_notifications/__init__.py create mode 100644 lib/crewai/src/crewai/a2a/updates/push_notifications/config.py create mode 100644 lib/crewai/src/crewai/a2a/updates/streaming/__init__.py create mode 100644 lib/crewai/src/crewai/a2a/updates/streaming/config.py diff --git a/lib/crewai/src/crewai/a2a/updates/__init__.py b/lib/crewai/src/crewai/a2a/updates/__init__.py new file mode 100644 index 0000000000..32ab762973 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/__init__.py @@ -0,0 +1,15 @@ +"""A2A update mechanism configuration types.""" + +from crewai.a2a.updates.polling.config import PollingConfig +from crewai.a2a.updates.push_notifications.config import PushNotificationConfig +from crewai.a2a.updates.streaming.config import StreamingConfig + + +UpdateConfig = PollingConfig | StreamingConfig | PushNotificationConfig + +__all__ = [ + "PollingConfig", + "PushNotificationConfig", + "StreamingConfig", + "UpdateConfig", +] diff --git a/lib/crewai/src/crewai/a2a/updates/polling/__init__.py b/lib/crewai/src/crewai/a2a/updates/polling/__init__.py new file mode 100644 index 0000000000..7199db700c --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/polling/__init__.py @@ -0,0 +1 @@ +"""Polling update mechanism module.""" diff --git a/lib/crewai/src/crewai/a2a/updates/polling/config.py b/lib/crewai/src/crewai/a2a/updates/polling/config.py new file mode 100644 index 0000000000..698c4b670d --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/polling/config.py @@ -0,0 +1,23 @@ +"""Polling update mechanism configuration.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class PollingConfig(BaseModel): + """Configuration for polling-based task updates. + + Attributes: + interval: Seconds between poll attempts. + timeout: Max seconds to poll before raising timeout error. + max_polls: Max number of poll attempts. + history_length: Number of messages to retrieve per poll. + """ + + interval: float = Field(default=2.0, description="Seconds between poll attempts") + timeout: float | None = Field(default=None, description="Max seconds to poll") + max_polls: int | None = Field(default=None, description="Max poll attempts") + history_length: int = Field( + default=100, description="Messages to retrieve per poll" + ) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/__init__.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/__init__.py new file mode 100644 index 0000000000..abb3c2f232 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/__init__.py @@ -0,0 +1 @@ +"""Push notification update mechanism module.""" diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py new file mode 100644 index 0000000000..9c683ae980 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py @@ -0,0 +1,25 @@ +"""Push notification update mechanism configuration.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from crewai.a2a.auth.schemas import AuthScheme + + +class PushNotificationConfig(BaseModel): + """Configuration for webhook-based task updates. + + Attributes: + url: Callback URL where agent sends push notifications. + id: Unique identifier for this config. + token: Token to validate incoming notifications. + authentication: Auth scheme for the callback endpoint. + """ + + url: str = Field(description="Callback URL for push notifications") + id: str | None = Field(default=None, description="Unique config identifier") + token: str | None = Field(default=None, description="Validation token") + authentication: AuthScheme | None = Field( + default=None, description="Authentication for callback endpoint" + ) diff --git a/lib/crewai/src/crewai/a2a/updates/streaming/__init__.py b/lib/crewai/src/crewai/a2a/updates/streaming/__init__.py new file mode 100644 index 0000000000..7adada8b58 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/streaming/__init__.py @@ -0,0 +1 @@ +"""Streaming update mechanism module.""" diff --git a/lib/crewai/src/crewai/a2a/updates/streaming/config.py b/lib/crewai/src/crewai/a2a/updates/streaming/config.py new file mode 100644 index 0000000000..6098bf5506 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/streaming/config.py @@ -0,0 +1,9 @@ +"""Streaming update mechanism configuration.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class StreamingConfig(BaseModel): + """Configuration for SSE-based task updates.""" From 7589e524ab70d36bb09332b6376707eb5a73a17c Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 18:47:28 -0500 Subject: [PATCH 02/34] feat: add shared task helpers and error types --- lib/crewai/src/crewai/a2a/errors.py | 7 + lib/crewai/src/crewai/a2a/task_helpers.py | 206 ++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 lib/crewai/src/crewai/a2a/errors.py create mode 100644 lib/crewai/src/crewai/a2a/task_helpers.py diff --git a/lib/crewai/src/crewai/a2a/errors.py b/lib/crewai/src/crewai/a2a/errors.py new file mode 100644 index 0000000000..e24e9c2969 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/errors.py @@ -0,0 +1,7 @@ +"""A2A protocol error types.""" + +from a2a.client.errors import A2AClientTimeoutError + + +class A2APollingTimeoutError(A2AClientTimeoutError): + """Raised when polling exceeds the configured timeout.""" diff --git a/lib/crewai/src/crewai/a2a/task_helpers.py b/lib/crewai/src/crewai/a2a/task_helpers.py new file mode 100644 index 0000000000..26f7201ef6 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/task_helpers.py @@ -0,0 +1,206 @@ +"""Helper functions for processing A2A task results.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, NotRequired, TypedDict +import uuid + +from a2a.types import AgentCard, Message, Part, Role, TaskState, TextPart + +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.a2a_events import A2AResponseReceivedEvent + + +if TYPE_CHECKING: + from a2a.types import Task as A2ATask + + +class TaskStateResult(TypedDict): + """Result dictionary from processing A2A task state.""" + + status: TaskState + history: list[Message] + result: NotRequired[str] + error: NotRequired[str] + agent_card: NotRequired[AgentCard] + + +def extract_task_result_parts(a2a_task: A2ATask) -> list[str]: + """Extract result parts from A2A task status message, history, and artifacts. + + Args: + a2a_task: A2A Task object with status, history, and artifacts + + Returns: + List of result text parts + """ + result_parts: list[str] = [] + + if a2a_task.status and a2a_task.status.message: + msg = a2a_task.status.message + result_parts.extend( + part.root.text for part in msg.parts if part.root.kind == "text" + ) + + if not result_parts and a2a_task.history: + for history_msg in reversed(a2a_task.history): + if history_msg.role == Role.agent: + result_parts.extend( + part.root.text + for part in history_msg.parts + if part.root.kind == "text" + ) + break + + if a2a_task.artifacts: + result_parts.extend( + part.root.text + for artifact in a2a_task.artifacts + for part in artifact.parts + if part.root.kind == "text" + ) + + return result_parts + + +def extract_error_message(a2a_task: A2ATask, default: str) -> str: + """Extract error message from A2A task. + + Args: + a2a_task: A2A Task object + default: Default message if no error found + + Returns: + Error message string + """ + if a2a_task.status and a2a_task.status.message: + msg = a2a_task.status.message + if msg: + for part in msg.parts: + if part.root.kind == "text": + return str(part.root.text) + return str(msg) + + if a2a_task.history: + for history_msg in reversed(a2a_task.history): + for part in history_msg.parts: + if part.root.kind == "text": + return str(part.root.text) + + return default + + +def process_task_state( + a2a_task: A2ATask, + new_messages: list[Message], + agent_card: AgentCard, + turn_number: int, + is_multiturn: bool, + agent_role: str | None, + result_parts: list[str] | None = None, +) -> TaskStateResult | None: + """Process A2A task state and return result dictionary. + + Shared logic for both polling and streaming handlers. + + Args: + a2a_task: The A2A task to process + new_messages: List to collect messages (modified in place) + agent_card: The agent card + turn_number: Current turn number + is_multiturn: Whether multi-turn conversation + agent_role: Agent role for logging + result_parts: Accumulated result parts (streaming passes accumulated, + polling passes None to extract from task) + + Returns: + Result dictionary if terminal/actionable state, None otherwise + """ + if result_parts is None: + result_parts = [] + + if a2a_task.status.state == TaskState.completed: + extracted_parts = extract_task_result_parts(a2a_task) + result_parts.extend(extracted_parts) + if a2a_task.history: + new_messages.extend(a2a_task.history) + + response_text = " ".join(result_parts) if result_parts else "" + crewai_event_bus.emit( + None, + A2AResponseReceivedEvent( + response=response_text, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="completed", + agent_role=agent_role, + ), + ) + + return TaskStateResult( + status=TaskState.completed, + agent_card=agent_card, + result=response_text, + history=new_messages, + ) + + if a2a_task.status.state == TaskState.input_required: + if a2a_task.history: + new_messages.extend(a2a_task.history) + + response_text = extract_error_message(a2a_task, "Additional input required") + if response_text and not a2a_task.history: + agent_message = Message( + role=Role.agent, + message_id=str(uuid.uuid4()), + parts=[Part(root=TextPart(text=response_text))], + context_id=getattr(a2a_task, "context_id", None), + task_id=getattr(a2a_task, "task_id", None), + ) + new_messages.append(agent_message) + + crewai_event_bus.emit( + None, + A2AResponseReceivedEvent( + response=response_text, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="input_required", + agent_role=agent_role, + ), + ) + + return TaskStateResult( + status=TaskState.input_required, + error=response_text, + history=new_messages, + agent_card=agent_card, + ) + + if a2a_task.status.state in {TaskState.failed, TaskState.rejected}: + error_msg = extract_error_message(a2a_task, "Task failed without error message") + if a2a_task.history: + new_messages.extend(a2a_task.history) + return TaskStateResult( + status=TaskState.failed, + error=error_msg, + history=new_messages, + ) + + if a2a_task.status.state == TaskState.auth_required: + error_msg = extract_error_message(a2a_task, "Authentication required") + return TaskStateResult( + status=TaskState.auth_required, + error=error_msg, + history=new_messages, + ) + + if a2a_task.status.state == TaskState.canceled: + error_msg = extract_error_message(a2a_task, "Task was canceled") + return TaskStateResult( + status=TaskState.canceled, + error=error_msg, + history=new_messages, + ) + + return None From 12d60a483a442b224b6ddc564ccb86bf0bf2a177 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 18:50:12 -0500 Subject: [PATCH 03/34] feat: add polling and streaming handlers --- .../src/crewai/a2a/updates/polling/handler.py | 228 ++++++++++++++++++ .../a2a/updates/push_notifications/handler.py | 3 + .../crewai/a2a/updates/streaming/handler.py | 152 ++++++++++++ .../src/crewai/events/types/a2a_events.py | 34 ++- 4 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 lib/crewai/src/crewai/a2a/updates/polling/handler.py create mode 100644 lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py create mode 100644 lib/crewai/src/crewai/a2a/updates/streaming/handler.py diff --git a/lib/crewai/src/crewai/a2a/updates/polling/handler.py b/lib/crewai/src/crewai/a2a/updates/polling/handler.py new file mode 100644 index 0000000000..7099538eb0 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/polling/handler.py @@ -0,0 +1,228 @@ +"""Polling update mechanism handler.""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Any + +from a2a.client import Client +from a2a.types import ( + AgentCard, + Message, + TaskQueryParams, + TaskState, +) + +from crewai.a2a.errors import A2APollingTimeoutError +from crewai.a2a.task_helpers import TaskStateResult, process_task_state +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.a2a_events import ( + A2APollingStartedEvent, + A2APollingStatusEvent, + A2AResponseReceivedEvent, +) + + +if TYPE_CHECKING: + from a2a.types import Task as A2ATask + + +TERMINAL_STATES = { + TaskState.completed, + TaskState.failed, + TaskState.rejected, + TaskState.canceled, +} + + +async def poll_task_until_complete( + client: Client, + task_id: str, + polling_interval: float, + polling_timeout: float, + agent_branch: Any | None = None, + history_length: int = 100, + max_polls: int | None = None, +) -> A2ATask: + """Poll task status until terminal state reached. + + Args: + client: A2A client instance + task_id: Task ID to poll + polling_interval: Seconds between poll attempts + polling_timeout: Max seconds before timeout + agent_branch: Agent tree branch for logging + history_length: Number of messages to retrieve per poll + max_polls: Max number of poll attempts (None = unlimited) + + Returns: + Final task object in terminal state + + Raises: + A2APollingTimeoutError: If polling exceeds timeout or max_polls + """ + start_time = time.monotonic() + poll_count = 0 + + while True: + poll_count += 1 + task = await client.get_task( + TaskQueryParams(id=task_id, history_length=history_length) + ) + + elapsed = time.monotonic() - start_time + crewai_event_bus.emit( + agent_branch, + A2APollingStatusEvent( + task_id=task_id, + state=str(task.status.state.value) if task.status.state else "unknown", + elapsed_seconds=elapsed, + poll_count=poll_count, + ), + ) + + if task.status.state in TERMINAL_STATES: + return task + + if task.status.state in {TaskState.input_required, TaskState.auth_required}: + return task + + if elapsed > polling_timeout: + raise A2APollingTimeoutError( + f"Polling timeout after {polling_timeout}s ({poll_count} polls)" + ) + + if max_polls and poll_count >= max_polls: + raise A2APollingTimeoutError( + f"Max polls ({max_polls}) exceeded after {elapsed:.1f}s" + ) + + await asyncio.sleep(polling_interval) + + +async def execute_polling_delegation( + client: Client, + message: Message, + polling_interval: float, + polling_timeout: float, + endpoint: str, + agent_branch: Any | None, + turn_number: int, + is_multiturn: bool, + agent_role: str | None, + new_messages: list[Message], + agent_card: AgentCard, + history_length: int = 100, + max_polls: int | None = None, +) -> TaskStateResult: + """Execute A2A delegation using polling for updates. + + Args: + client: A2A client instance + message: Message to send + polling_interval: Seconds between poll attempts + polling_timeout: Max seconds before timeout + endpoint: A2A agent endpoint URL + agent_branch: Agent tree branch for logging + turn_number: Current turn number + is_multiturn: Whether this is a multi-turn conversation + agent_role: Agent role for logging + new_messages: List to collect messages + agent_card: The agent card + history_length: Number of messages to retrieve per poll + max_polls: Max number of poll attempts (None = unlimited) + + Returns: + Dictionary with status, result/error, and history + """ + task_id: str | None = None + + async for event in client.send_message(message): + if isinstance(event, Message): + new_messages.append(event) + result_parts = [ + part.root.text for part in event.parts if part.root.kind == "text" + ] + response_text = " ".join(result_parts) if result_parts else "" + + crewai_event_bus.emit( + None, + A2AResponseReceivedEvent( + response=response_text, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="completed", + agent_role=agent_role, + ), + ) + + return TaskStateResult( + status=TaskState.completed, + result=response_text, + history=new_messages, + agent_card=agent_card, + ) + + if isinstance(event, tuple): + a2a_task, _ = event + task_id = a2a_task.id + + if a2a_task.status.state in TERMINAL_STATES | { + TaskState.input_required, + TaskState.auth_required, + }: + result = process_task_state( + a2a_task=a2a_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result + break + + if not task_id: + return TaskStateResult( + status=TaskState.failed, + error="No task ID received from initial message", + history=new_messages, + ) + + crewai_event_bus.emit( + agent_branch, + A2APollingStartedEvent( + task_id=task_id, + polling_interval=polling_interval, + endpoint=endpoint, + ), + ) + + final_task = await poll_task_until_complete( + client=client, + task_id=task_id, + polling_interval=polling_interval, + polling_timeout=polling_timeout, + agent_branch=agent_branch, + history_length=history_length, + max_polls=max_polls, + ) + + result = process_task_state( + a2a_task=final_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result + + return TaskStateResult( + status=TaskState.failed, + error=f"Unexpected task state: {final_task.status.state}", + history=new_messages, + ) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py new file mode 100644 index 0000000000..cff96bfaa3 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py @@ -0,0 +1,3 @@ +"""Push notification (webhook) update mechanism handler.""" + +from __future__ import annotations diff --git a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py new file mode 100644 index 0000000000..b453c687c9 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py @@ -0,0 +1,152 @@ +"""Streaming (SSE) update mechanism handler.""" + +from __future__ import annotations + +import uuid + +from a2a.client import Client +from a2a.client.errors import A2AClientHTTPError +from a2a.types import ( + AgentCard, + Message, + Part, + Role, + TaskArtifactUpdateEvent, + TaskState, + TaskStatusUpdateEvent, + TextPart, +) + +from crewai.a2a.task_helpers import TaskStateResult, process_task_state +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.a2a_events import A2AResponseReceivedEvent + + +async def execute_streaming_delegation( + client: Client, + message: Message, + context_id: str | None, + task_id: str | None, + turn_number: int, + is_multiturn: bool, + agent_role: str | None, + new_messages: list[Message], + agent_card: AgentCard, +) -> TaskStateResult: + """Execute A2A delegation using SSE streaming for updates. + + Args: + client: A2A client instance + message: Message to send + context_id: Context ID for correlation + task_id: Task ID for correlation + turn_number: Current turn number + is_multiturn: Whether this is a multi-turn conversation + agent_role: Agent role for logging + new_messages: List to collect messages + agent_card: The agent card + + Returns: + Dictionary with status, result/error, and history + """ + result_parts: list[str] = [] + final_result: TaskStateResult | None = None + event_stream = client.send_message(message) + + try: + async for event in event_stream: + if isinstance(event, Message): + new_messages.append(event) + for part in event.parts: + if part.root.kind == "text": + text = part.root.text + result_parts.append(text) + + elif isinstance(event, tuple): + a2a_task, update = event + + if isinstance(update, TaskArtifactUpdateEvent): + artifact = update.artifact + result_parts.extend( + part.root.text + for part in artifact.parts + if part.root.kind == "text" + ) + + is_final_update = False + if isinstance(update, TaskStatusUpdateEvent): + is_final_update = update.final + + if not is_final_update and a2a_task.status.state not in [ + TaskState.completed, + TaskState.input_required, + TaskState.failed, + TaskState.rejected, + TaskState.auth_required, + TaskState.canceled, + ]: + continue + + final_result = process_task_state( + a2a_task=a2a_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + result_parts=result_parts, + ) + if final_result: + break + + except A2AClientHTTPError as e: + error_msg = f"HTTP Error {e.status_code}: {e!s}" + + error_message = Message( + role=Role.agent, + message_id=str(uuid.uuid4()), + parts=[Part(root=TextPart(text=error_msg))], + context_id=context_id, + task_id=task_id, + ) + new_messages.append(error_message) + + crewai_event_bus.emit( + None, + A2AResponseReceivedEvent( + response=error_msg, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="failed", + agent_role=agent_role, + ), + ) + return TaskStateResult( + status=TaskState.failed, + error=error_msg, + history=new_messages, + ) + + except Exception as e: + current_exception: Exception | BaseException | None = e + while current_exception: + if hasattr(current_exception, "response"): + response = current_exception.response + if hasattr(response, "text"): + break + if current_exception and hasattr(current_exception, "__cause__"): + current_exception = current_exception.__cause__ + raise + + finally: + if hasattr(event_stream, "aclose"): + await event_stream.aclose() + + if final_result: + return final_result + + return TaskStateResult( + status=TaskState.completed, + result=" ".join(result_parts) if result_parts else "", + history=new_messages, + ) diff --git a/lib/crewai/src/crewai/events/types/a2a_events.py b/lib/crewai/src/crewai/events/types/a2a_events.py index baafd53c39..6afd1533d2 100644 --- a/lib/crewai/src/crewai/events/types/a2a_events.py +++ b/lib/crewai/src/crewai/events/types/a2a_events.py @@ -15,7 +15,7 @@ class A2AEventBase(BaseEvent): from_task: Any | None = None from_agent: Any | None = None - def __init__(self, **data): + def __init__(self, **data: Any) -> None: """Initialize A2A event, extracting task and agent metadata.""" if data.get("from_task"): task = data["from_task"] @@ -139,3 +139,35 @@ class A2AConversationCompletedEvent(A2AEventBase): final_result: str | None = None error: str | None = None total_turns: int + + +class A2APollingStartedEvent(A2AEventBase): + """Event emitted when polling mode begins for A2A delegation. + + Attributes: + task_id: A2A task ID being polled + polling_interval: Seconds between poll attempts + endpoint: A2A agent endpoint URL + """ + + type: str = "a2a_polling_started" + task_id: str + polling_interval: float + endpoint: str + + +class A2APollingStatusEvent(A2AEventBase): + """Event emitted on each polling iteration. + + Attributes: + task_id: A2A task ID being polled + state: Current task state from remote agent + elapsed_seconds: Time since polling started + poll_count: Number of polls completed + """ + + type: str = "a2a_polling_status" + task_id: str + state: str + elapsed_seconds: float + poll_count: int From f478004e11e3e5ba3de5c412ed13813165056dc0 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 18:58:12 -0500 Subject: [PATCH 04/34] chore: use TaskStateResult and TaskState enum --- lib/crewai/src/crewai/a2a/config.py | 29 ++- lib/crewai/src/crewai/a2a/utils.py | 336 +++++---------------------- lib/crewai/src/crewai/a2a/wrapper.py | 30 +-- 3 files changed, 96 insertions(+), 299 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/config.py b/lib/crewai/src/crewai/a2a/config.py index c536028825..d7ac85bae7 100644 --- a/lib/crewai/src/crewai/a2a/config.py +++ b/lib/crewai/src/crewai/a2a/config.py @@ -5,17 +5,19 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from pydantic import ( BaseModel, BeforeValidator, + ConfigDict, Field, HttpUrl, TypeAdapter, ) from crewai.a2a.auth.schemas import AuthScheme +from crewai.a2a.updates import StreamingConfig, UpdateConfig http_url_adapter = TypeAdapter(HttpUrl) @@ -33,18 +35,21 @@ class A2AConfig(BaseModel): Attributes: endpoint: A2A agent endpoint URL. - auth: Authentication scheme (Bearer, OAuth2, API Key, HTTP Basic/Digest). - timeout: Request timeout in seconds (default: 120). - max_turns: Maximum conversation turns with A2A agent (default: 10). + auth: Authentication scheme. + timeout: Request timeout in seconds. + max_turns: Maximum conversation turns with A2A agent. response_model: Optional Pydantic model for structured A2A agent responses. - fail_fast: If True, raise error when agent unreachable; if False, skip and continue (default: True). - trust_remote_completion_status: If True, return A2A agent's result directly when status is "completed"; if False, always ask server agent to respond (default: False). + fail_fast: If True, raise error when agent unreachable; if False, skip and continue. + trust_remote_completion_status: If True, return A2A agent's result directly when completed. + updates: Update mechanism config. """ + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + endpoint: Url = Field(description="A2A agent endpoint URL") auth: AuthScheme | None = Field( default=None, - description="Authentication scheme (Bearer, OAuth2, API Key, HTTP Basic/Digest)", + description="Authentication scheme", ) timeout: int = Field(default=120, description="Request timeout in seconds") max_turns: int = Field( @@ -52,13 +57,17 @@ class A2AConfig(BaseModel): ) response_model: type[BaseModel] | None = Field( default=None, - description="Optional Pydantic model for structured A2A agent responses. When specified, the A2A agent is expected to return JSON matching this schema.", + description="Optional Pydantic model for structured A2A agent responses", ) fail_fast: bool = Field( default=True, - description="If True, raise an error immediately when the A2A agent is unreachable. If False, skip the A2A agent and continue execution.", + description="If True, raise error when agent unreachable; if False, skip", ) trust_remote_completion_status: bool = Field( default=False, - description='If True, return the A2A agent\'s result directly when status is "completed" without asking the server agent to respond. If False, always ask the server agent to respond, allowing it to potentially delegate again.', + description="If True, return A2A result directly when completed", + ) + updates: UpdateConfig = Field( + default_factory=StreamingConfig, + description="Update mechanism config", ) diff --git a/lib/crewai/src/crewai/a2a/utils.py b/lib/crewai/src/crewai/a2a/utils.py index 4bbadc00c5..01807ce30b 100644 --- a/lib/crewai/src/crewai/a2a/utils.py +++ b/lib/crewai/src/crewai/a2a/utils.py @@ -10,16 +10,12 @@ from typing import TYPE_CHECKING, Any import uuid -from a2a.client import Client, ClientConfig, ClientFactory -from a2a.client.errors import A2AClientHTTPError +from a2a.client import A2AClientHTTPError, Client, ClientConfig, ClientFactory from a2a.types import ( AgentCard, Message, Part, Role, - TaskArtifactUpdateEvent, - TaskState, - TaskStatusUpdateEvent, TextPart, TransportProtocol, ) @@ -36,20 +32,23 @@ validate_auth_against_agent_card, ) from crewai.a2a.config import A2AConfig +from crewai.a2a.task_helpers import TaskStateResult from crewai.a2a.types import PartsDict, PartsMetadataDict +from crewai.a2a.updates import PollingConfig, UpdateConfig +from crewai.a2a.updates.polling.handler import execute_polling_delegation +from crewai.a2a.updates.streaming.handler import execute_streaming_delegation from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import ( A2AConversationStartedEvent, A2ADelegationCompletedEvent, A2ADelegationStartedEvent, A2AMessageSentEvent, - A2AResponseReceivedEvent, ) from crewai.types.utils import create_literals_from_strings if TYPE_CHECKING: - from a2a.types import Message, Task as A2ATask + from a2a.types import Message from crewai.a2a.auth.schemas import AuthScheme @@ -235,26 +234,20 @@ def execute_a2a_delegation( agent_branch: Any | None = None, response_model: type[BaseModel] | None = None, turn_number: int | None = None, -) -> dict[str, Any]: + updates: UpdateConfig | None = None, +) -> TaskStateResult: """Execute a task delegation to a remote A2A agent with multi-turn support. - Handles: - - AgentCard discovery - - Authentication setup - - Message creation and sending - - Response parsing - - Multi-turn conversations - Args: - endpoint: A2A agent endpoint URL (AgentCard URL) - auth: Optional AuthScheme for authentication (Bearer, OAuth2, API Key, HTTP Basic/Digest) + endpoint: A2A agent endpoint URL + auth: Optional AuthScheme for authentication timeout: Request timeout in seconds task_description: The task to delegate context: Optional context information context_id: Context ID for correlating messages/tasks task_id: Specific task identifier reference_task_ids: List of related task IDs - metadata: Additional metadata (external_id, request_id, etc.) + metadata: Additional metadata extensions: Protocol extensions for custom fields conversation_history: Previous Message objects from conversation agent_id: Agent identifier for logging @@ -262,16 +255,10 @@ def execute_a2a_delegation( agent_branch: Optional agent tree branch for logging response_model: Optional Pydantic model for structured outputs turn_number: Optional turn number for multi-turn conversations + updates: Update mechanism config from A2AConfig.updates Returns: - Dictionary with: - - status: "completed", "input_required", "failed", etc. - - result: Result string (if completed) - - error: Error message (if failed) - - history: List of new Message objects from this exchange - - Raises: - ImportError: If a2a-sdk is not installed + TaskStateResult with status, result/error, history, and agent_card """ is_multiturn = bool(conversation_history and len(conversation_history) > 0) if turn_number is None: @@ -311,6 +298,7 @@ def execute_a2a_delegation( agent_id=agent_id, agent_role=agent_role, response_model=response_model, + updates=updates, ) ) @@ -347,7 +335,8 @@ async def _execute_a2a_delegation_async( agent_id: str | None = None, agent_role: str | None = None, response_model: type[BaseModel] | None = None, -) -> dict[str, Any]: + updates: UpdateConfig | None = None, +) -> TaskStateResult: """Async implementation of A2A delegation with multi-turn support. Args: @@ -368,9 +357,10 @@ async def _execute_a2a_delegation_async( agent_id: Agent identifier for logging agent_role: Agent role for logging response_model: Optional Pydantic model for structured outputs + updates: Update mechanism config Returns: - Dictionary with status, result/error, and new history + TaskStateResult with status, result/error, history, and agent_card """ if auth: auth_data = auth.model_dump_json( @@ -458,201 +448,52 @@ async def _execute_a2a_delegation_async( ), ) + polling_config = updates if isinstance(updates, PollingConfig) else None + use_polling = polling_config is not None + polling_interval = polling_config.interval if polling_config else 2.0 + effective_polling_timeout = ( + polling_config.timeout + if polling_config and polling_config.timeout + else float(timeout) + ) + async with _create_a2a_client( agent_card=agent_card, transport_protocol=transport_protocol, timeout=timeout, headers=headers, - streaming=True, + streaming=not use_polling, auth=auth, + use_polling=use_polling, ) as client: - result_parts: list[str] = [] - final_result: dict[str, Any] | None = None - event_stream = client.send_message(message) - - try: - async for event in event_stream: - if isinstance(event, Message): - new_messages.append(event) - for part in event.parts: - if part.root.kind == "text": - text = part.root.text - result_parts.append(text) - - elif isinstance(event, tuple): - a2a_task, update = event - - if isinstance(update, TaskArtifactUpdateEvent): - artifact = update.artifact - result_parts.extend( - part.root.text - for part in artifact.parts - if part.root.kind == "text" - ) - - is_final_update = False - if isinstance(update, TaskStatusUpdateEvent): - is_final_update = update.final - - if not is_final_update and a2a_task.status.state not in [ - TaskState.completed, - TaskState.input_required, - TaskState.failed, - TaskState.rejected, - TaskState.auth_required, - TaskState.canceled, - ]: - continue - - if a2a_task.status.state == TaskState.completed: - extracted_parts = _extract_task_result_parts(a2a_task) - result_parts.extend(extracted_parts) - if a2a_task.history: - new_messages.extend(a2a_task.history) - - response_text = " ".join(result_parts) if result_parts else "" - crewai_event_bus.emit( - None, - A2AResponseReceivedEvent( - response=response_text, - turn_number=turn_number, - is_multiturn=is_multiturn, - status="completed", - agent_role=agent_role, - ), - ) - - final_result = { - "status": "completed", - "result": response_text, - "history": new_messages, - "agent_card": agent_card, - } - break - - if a2a_task.status.state == TaskState.input_required: - if a2a_task.history: - new_messages.extend(a2a_task.history) - - response_text = _extract_error_message( - a2a_task, "Additional input required" - ) - if response_text and not a2a_task.history: - agent_message = Message( - role=Role.agent, - message_id=str(uuid.uuid4()), - parts=[Part(root=TextPart(text=response_text))], - context_id=a2a_task.context_id - if hasattr(a2a_task, "context_id") - else None, - task_id=a2a_task.task_id - if hasattr(a2a_task, "task_id") - else None, - ) - new_messages.append(agent_message) - crewai_event_bus.emit( - None, - A2AResponseReceivedEvent( - response=response_text, - turn_number=turn_number, - is_multiturn=is_multiturn, - status="input_required", - agent_role=agent_role, - ), - ) - - final_result = { - "status": "input_required", - "error": response_text, - "history": new_messages, - "agent_card": agent_card, - } - break - - if a2a_task.status.state in [TaskState.failed, TaskState.rejected]: - error_msg = _extract_error_message( - a2a_task, "Task failed without error message" - ) - if a2a_task.history: - new_messages.extend(a2a_task.history) - final_result = { - "status": "failed", - "error": error_msg, - "history": new_messages, - } - break - - if a2a_task.status.state == TaskState.auth_required: - error_msg = _extract_error_message( - a2a_task, "Authentication required" - ) - final_result = { - "status": "auth_required", - "error": error_msg, - "history": new_messages, - } - break - - if a2a_task.status.state == TaskState.canceled: - error_msg = _extract_error_message( - a2a_task, "Task was canceled" - ) - final_result = { - "status": "canceled", - "error": error_msg, - "history": new_messages, - } - break - except Exception as e: - if isinstance(e, A2AClientHTTPError): - error_msg = f"HTTP Error {e.status_code}: {e!s}" - - error_message = Message( - role=Role.agent, - message_id=str(uuid.uuid4()), - parts=[Part(root=TextPart(text=error_msg))], - context_id=context_id, - task_id=task_id, - ) - new_messages.append(error_message) - - crewai_event_bus.emit( - None, - A2AResponseReceivedEvent( - response=error_msg, - turn_number=turn_number, - is_multiturn=is_multiturn, - status="failed", - agent_role=agent_role, - ), - ) - return { - "status": "failed", - "error": error_msg, - "history": new_messages, - } - - current_exception: Exception | BaseException | None = e - while current_exception: - if hasattr(current_exception, "response"): - response = current_exception.response - if hasattr(response, "text"): - break - if current_exception and hasattr(current_exception, "__cause__"): - current_exception = current_exception.__cause__ - raise - finally: - if hasattr(event_stream, "aclose"): - await event_stream.aclose() - - if final_result: - return final_result + if use_polling and polling_config: + return await execute_polling_delegation( + client=client, + message=message, + polling_interval=polling_interval, + polling_timeout=effective_polling_timeout, + endpoint=endpoint, + agent_branch=agent_branch, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + new_messages=new_messages, + agent_card=agent_card, + history_length=polling_config.history_length, + max_polls=polling_config.max_polls, + ) - return { - "status": "completed", - "result": " ".join(result_parts) if result_parts else "", - "history": new_messages, - } + return await execute_streaming_delegation( + client=client, + message=message, + context_id=context_id, + task_id=task_id, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + new_messages=new_messages, + agent_card=agent_card, + ) @asynccontextmanager @@ -663,6 +504,7 @@ async def _create_a2a_client( headers: MutableMapping[str, str], streaming: bool, auth: AuthScheme | None = None, + use_polling: bool = False, ) -> AsyncIterator[Client]: """Create and configure an A2A client. @@ -673,6 +515,7 @@ async def _create_a2a_client( headers: HTTP headers (already with auth applied) streaming: Enable streaming responses auth: Optional AuthScheme for client configuration + use_polling: Enable polling mode Yields: Configured A2A client instance @@ -688,7 +531,8 @@ async def _create_a2a_client( config = ClientConfig( httpx_client=httpx_client, supported_transports=[str(transport_protocol.value)], - streaming=streaming, + streaming=streaming and not use_polling, + polling=use_polling, accepted_output_modes=["application/json"], ) @@ -697,66 +541,6 @@ async def _create_a2a_client( yield client -def _extract_task_result_parts(a2a_task: A2ATask) -> list[str]: - """Extract result parts from A2A task history and artifacts. - - Args: - a2a_task: A2A Task object with history and artifacts - - Returns: - List of result text parts - """ - - result_parts: list[str] = [] - - if a2a_task.history: - for history_msg in reversed(a2a_task.history): - if history_msg.role == Role.agent: - result_parts.extend( - part.root.text - for part in history_msg.parts - if part.root.kind == "text" - ) - break - - if a2a_task.artifacts: - result_parts.extend( - part.root.text - for artifact in a2a_task.artifacts - for part in artifact.parts - if part.root.kind == "text" - ) - - return result_parts - - -def _extract_error_message(a2a_task: A2ATask, default: str) -> str: - """Extract error message from A2A task. - - Args: - a2a_task: A2A Task object - default: Default message if no error found - - Returns: - Error message string - """ - if a2a_task.status and a2a_task.status.message: - msg = a2a_task.status.message - if msg: - for part in msg.parts: - if part.root.kind == "text": - return str(part.root.text) - return str(msg) - - if a2a_task.history: - for history_msg in reversed(a2a_task.history): - for part in history_msg.parts: - if part.root.kind == "text": - return str(part.root.text) - - return default - - def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel]: """Create a dynamic AgentResponse model with Literal types for agent IDs. diff --git a/lib/crewai/src/crewai/a2a/wrapper.py b/lib/crewai/src/crewai/a2a/wrapper.py index 4c98e6f30d..6b9ae022a4 100644 --- a/lib/crewai/src/crewai/a2a/wrapper.py +++ b/lib/crewai/src/crewai/a2a/wrapper.py @@ -9,13 +9,14 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from functools import wraps from types import MethodType -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any -from a2a.types import Role +from a2a.types import Role, TaskState from pydantic import BaseModel, ValidationError from crewai.a2a.config import A2AConfig from crewai.a2a.extensions.base import ExtensionRegistry +from crewai.a2a.task_helpers import TaskStateResult from crewai.a2a.templates import ( AVAILABLE_AGENTS_TEMPLATE, CONVERSATION_TURN_INFO_TEMPLATE, @@ -346,7 +347,7 @@ def _augment_prompt_with_a2a( def _parse_agent_response( raw_result: str | dict[str, Any], agent_response_model: type[BaseModel] -) -> BaseModel | str: +) -> BaseModel | str | dict[str, Any]: """Parse LLM output as AgentResponse or return raw agent response. Args: @@ -354,7 +355,7 @@ def _parse_agent_response( agent_response_model: The agent response model Returns: - Parsed AgentResponse or string + Parsed AgentResponse, or raw result if parsing fails """ if agent_response_model: try: @@ -363,13 +364,13 @@ def _parse_agent_response( if isinstance(raw_result, dict): return agent_response_model.model_validate(raw_result) except ValidationError: - return cast(str, raw_result) - return cast(str, raw_result) + return raw_result + return raw_result def _handle_agent_response_and_continue( self: Agent, - a2a_result: dict[str, Any], + a2a_result: TaskStateResult, agent_id: str, agent_cards: dict[str, AgentCard] | None, a2a_agents: list[A2AConfig], @@ -568,6 +569,7 @@ def _delegate_to_a2a( agent_branch=agent_branch, response_model=agent_config.response_model, turn_number=turn_num + 1, + updates=agent_config.updates, ) conversation_history = a2a_result.get("history", []) @@ -579,11 +581,8 @@ def _delegate_to_a2a( if latest_message.context_id is not None: context_id = latest_message.context_id - if a2a_result["status"] in ["completed", "input_required"]: - if ( - a2a_result["status"] == "completed" - and agent_config.trust_remote_completion_status - ): + if a2a_result["status"] in [TaskState.completed, TaskState.input_required]: + if a2a_result["status"] == TaskState.completed: if ( task_id_config is not None and task_id_config not in reference_task_ids @@ -592,7 +591,12 @@ def _delegate_to_a2a( if task.config is None: task.config = {} task.config["reference_task_ids"] = reference_task_ids + task_id_config = None + if ( + a2a_result["status"] == TaskState.completed + and agent_config.trust_remote_completion_status + ): result_text = a2a_result.get("result", "") final_turn_number = turn_num + 1 crewai_event_bus.emit( @@ -604,7 +608,7 @@ def _delegate_to_a2a( total_turns=final_turn_number, ), ) - return cast(str, result_text) + return str(result_text) final_result, next_request = _handle_agent_response_and_continue( self=self, From c75391dfbb42a961943402503858b9ded12b158f Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 18:59:50 -0500 Subject: [PATCH 05/34] feat: activate polling events --- lib/crewai/src/crewai/a2a/__init__.py | 14 +++++- .../src/crewai/events/event_listener.py | 20 +++++++- .../crewai/events/utils/console_formatter.py | 47 ++++++++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/__init__.py b/lib/crewai/src/crewai/a2a/__init__.py index 352ad445e2..e5065ddd9a 100644 --- a/lib/crewai/src/crewai/a2a/__init__.py +++ b/lib/crewai/src/crewai/a2a/__init__.py @@ -1,6 +1,18 @@ """Agent-to-Agent (A2A) protocol communication module for CrewAI.""" from crewai.a2a.config import A2AConfig +from crewai.a2a.errors import A2APollingTimeoutError +from crewai.a2a.updates import ( + PollingConfig, + PushNotificationConfig, + StreamingConfig, +) -__all__ = ["A2AConfig"] +__all__ = [ + "A2AConfig", + "A2APollingTimeoutError", + "PollingConfig", + "PushNotificationConfig", + "StreamingConfig", +] diff --git a/lib/crewai/src/crewai/events/event_listener.py b/lib/crewai/src/crewai/events/event_listener.py index 36c37e9c9a..bc69211c69 100644 --- a/lib/crewai/src/crewai/events/event_listener.py +++ b/lib/crewai/src/crewai/events/event_listener.py @@ -13,6 +13,8 @@ A2ADelegationCompletedEvent, A2ADelegationStartedEvent, A2AMessageSentEvent, + A2APollingStartedEvent, + A2APollingStatusEvent, A2AResponseReceivedEvent, ) from crewai.events.types.agent_events import ( @@ -67,7 +69,6 @@ MCPConnectionCompletedEvent, MCPConnectionFailedEvent, MCPConnectionStartedEvent, - MCPToolExecutionCompletedEvent, MCPToolExecutionFailedEvent, MCPToolExecutionStartedEvent, ) @@ -580,6 +581,23 @@ def on_a2a_conversation_completed( event.total_turns, ) + @crewai_event_bus.on(A2APollingStartedEvent) + def on_a2a_polling_started(_: Any, event: A2APollingStartedEvent) -> None: + self.formatter.handle_a2a_polling_started( + event.task_id, + event.polling_interval, + event.endpoint, + ) + + @crewai_event_bus.on(A2APollingStatusEvent) + def on_a2a_polling_status(_: Any, event: A2APollingStatusEvent) -> None: + self.formatter.handle_a2a_polling_status( + event.task_id, + event.state, + event.elapsed_seconds, + event.poll_count, + ) + # ----------- MCP EVENTS ----------- @crewai_event_bus.on(MCPConnectionStartedEvent) diff --git a/lib/crewai/src/crewai/events/utils/console_formatter.py b/lib/crewai/src/crewai/events/utils/console_formatter.py index a395db39f1..e0d2c50553 100644 --- a/lib/crewai/src/crewai/events/utils/console_formatter.py +++ b/lib/crewai/src/crewai/events/utils/console_formatter.py @@ -114,7 +114,6 @@ def resume_live_updates(self) -> None: New streaming sessions will be created on-demand when needed. This method exists for API compatibility with HITL callers. """ - pass def print_panel( self, content: Text, title: str, style: str = "blue", is_flow: bool = False @@ -1417,3 +1416,49 @@ def handle_mcp_tool_execution_failed( panel = self.create_panel(content, "❌ MCP Tool Failed", "red") self.print(panel) self.print() + + def handle_a2a_polling_started( + self, + task_id: str, + polling_interval: float, + endpoint: str, + ) -> None: + """Handle A2A polling started event with panel display.""" + content = Text() + content.append("A2A Polling Started\n", style="cyan bold") + content.append("Task ID: ", style="white") + content.append(f"{task_id[:8]}...\n", style="cyan") + content.append("Interval: ", style="white") + content.append(f"{polling_interval}s\n", style="cyan") + + self.print_panel(content, "⏳ A2A Polling", "cyan") + + def handle_a2a_polling_status( + self, + task_id: str, + state: str, + elapsed_seconds: float, + poll_count: int, + ) -> None: + """Handle A2A polling status event with panel display.""" + if state == "completed": + style = "green" + status_indicator = "✓" + elif state == "failed": + style = "red" + status_indicator = "✗" + elif state == "working": + style = "yellow" + status_indicator = "⋯" + else: + style = "cyan" + status_indicator = "•" + + content = Text() + content.append(f"Poll #{poll_count}\n", style=f"{style} bold") + content.append("Status: ", style="white") + content.append(f"{status_indicator} {state}\n", style=style) + content.append("Elapsed: ", style="white") + content.append(f"{elapsed_seconds:.1f}s\n", style=style) + + self.print_panel(content, f"📊 A2A Poll #{poll_count}", style) From 33caeeba28fde1981fd5afbceb6add8e4d350971 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 20:07:48 -0500 Subject: [PATCH 06/34] chore: refactor handlers to unified protocol --- lib/crewai/src/crewai/a2a/task_helpers.py | 21 +- lib/crewai/src/crewai/a2a/updates/__init__.py | 18 ++ lib/crewai/src/crewai/a2a/updates/base.py | 68 +++++ .../src/crewai/a2a/updates/polling/config.py | 10 +- .../src/crewai/a2a/updates/polling/handler.py | 245 +++++++++--------- .../a2a/updates/push_notifications/config.py | 4 +- .../a2a/updates/push_notifications/handler.py | 37 +++ .../crewai/a2a/updates/streaming/handler.py | 235 ++++++++--------- lib/crewai/src/crewai/a2a/utils.py | 92 ++++--- 9 files changed, 440 insertions(+), 290 deletions(-) create mode 100644 lib/crewai/src/crewai/a2a/updates/base.py diff --git a/lib/crewai/src/crewai/a2a/task_helpers.py b/lib/crewai/src/crewai/a2a/task_helpers.py index 26f7201ef6..2d34b3c406 100644 --- a/lib/crewai/src/crewai/a2a/task_helpers.py +++ b/lib/crewai/src/crewai/a2a/task_helpers.py @@ -15,6 +15,23 @@ from a2a.types import Task as A2ATask +TERMINAL_STATES: frozenset[TaskState] = frozenset( + { + TaskState.completed, + TaskState.failed, + TaskState.rejected, + TaskState.canceled, + } +) + +ACTIONABLE_STATES: frozenset[TaskState] = frozenset( + { + TaskState.input_required, + TaskState.auth_required, + } +) + + class TaskStateResult(TypedDict): """Result dictionary from processing A2A task state.""" @@ -154,8 +171,8 @@ def process_task_state( role=Role.agent, message_id=str(uuid.uuid4()), parts=[Part(root=TextPart(text=response_text))], - context_id=getattr(a2a_task, "context_id", None), - task_id=getattr(a2a_task, "task_id", None), + context_id=a2a_task.context_id, + task_id=a2a_task.id, ) new_messages.append(agent_message) diff --git a/lib/crewai/src/crewai/a2a/updates/__init__.py b/lib/crewai/src/crewai/a2a/updates/__init__.py index 32ab762973..79b7f35b45 100644 --- a/lib/crewai/src/crewai/a2a/updates/__init__.py +++ b/lib/crewai/src/crewai/a2a/updates/__init__.py @@ -1,15 +1,33 @@ """A2A update mechanism configuration types.""" +from crewai.a2a.updates.base import ( + BaseHandlerKwargs, + PollingHandlerKwargs, + PushNotificationHandlerKwargs, + StreamingHandlerKwargs, + UpdateHandler, +) from crewai.a2a.updates.polling.config import PollingConfig +from crewai.a2a.updates.polling.handler import PollingHandler from crewai.a2a.updates.push_notifications.config import PushNotificationConfig +from crewai.a2a.updates.push_notifications.handler import PushNotificationHandler from crewai.a2a.updates.streaming.config import StreamingConfig +from crewai.a2a.updates.streaming.handler import StreamingHandler UpdateConfig = PollingConfig | StreamingConfig | PushNotificationConfig __all__ = [ + "BaseHandlerKwargs", "PollingConfig", + "PollingHandler", + "PollingHandlerKwargs", "PushNotificationConfig", + "PushNotificationHandler", + "PushNotificationHandlerKwargs", "StreamingConfig", + "StreamingHandler", + "StreamingHandlerKwargs", "UpdateConfig", + "UpdateHandler", ] diff --git a/lib/crewai/src/crewai/a2a/updates/base.py b/lib/crewai/src/crewai/a2a/updates/base.py new file mode 100644 index 0000000000..34016f955d --- /dev/null +++ b/lib/crewai/src/crewai/a2a/updates/base.py @@ -0,0 +1,68 @@ +"""Base types for A2A update mechanism handlers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, TypedDict + + +if TYPE_CHECKING: + from a2a.client import Client + from a2a.types import AgentCard, Message + + from crewai.a2a.task_helpers import TaskStateResult + + +class BaseHandlerKwargs(TypedDict, total=False): + """Base kwargs shared by all handlers.""" + + turn_number: int + is_multiturn: bool + agent_role: str | None + + +class PollingHandlerKwargs(BaseHandlerKwargs, total=False): + """Kwargs for polling handler.""" + + polling_interval: float + polling_timeout: float + endpoint: str + agent_branch: Any + history_length: int + max_polls: int | None + + +class StreamingHandlerKwargs(BaseHandlerKwargs, total=False): + """Kwargs for streaming handler.""" + + context_id: str | None + task_id: str | None + + +class PushNotificationHandlerKwargs(BaseHandlerKwargs, total=False): + """Kwargs for push notification handler.""" + + +class UpdateHandler(Protocol): + """Protocol for A2A update mechanism handlers.""" + + @staticmethod + async def execute( + client: Client, + message: Message, + new_messages: list[Message], + agent_card: AgentCard, + **kwargs: Any, + ) -> TaskStateResult: + """Execute the update mechanism and return result. + + Args: + client: A2A client instance. + message: Message to send. + new_messages: List to collect messages (modified in place). + agent_card: The agent card. + **kwargs: Additional handler-specific parameters. + + Returns: + Result dictionary with status, result/error, and history. + """ + ... diff --git a/lib/crewai/src/crewai/a2a/updates/polling/config.py b/lib/crewai/src/crewai/a2a/updates/polling/config.py index 698c4b670d..1dcf970a6a 100644 --- a/lib/crewai/src/crewai/a2a/updates/polling/config.py +++ b/lib/crewai/src/crewai/a2a/updates/polling/config.py @@ -15,9 +15,11 @@ class PollingConfig(BaseModel): history_length: Number of messages to retrieve per poll. """ - interval: float = Field(default=2.0, description="Seconds between poll attempts") - timeout: float | None = Field(default=None, description="Max seconds to poll") - max_polls: int | None = Field(default=None, description="Max poll attempts") + interval: float = Field( + default=2.0, gt=0, description="Seconds between poll attempts" + ) + timeout: float | None = Field(default=None, gt=0, description="Max seconds to poll") + max_polls: int | None = Field(default=None, gt=0, description="Max poll attempts") history_length: int = Field( - default=100, description="Messages to retrieve per poll" + default=100, gt=0, description="Messages to retrieve per poll" ) diff --git a/lib/crewai/src/crewai/a2a/updates/polling/handler.py b/lib/crewai/src/crewai/a2a/updates/polling/handler.py index 7099538eb0..453affe210 100644 --- a/lib/crewai/src/crewai/a2a/updates/polling/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/polling/handler.py @@ -4,7 +4,7 @@ import asyncio import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Unpack from a2a.client import Client from a2a.types import ( @@ -15,7 +15,13 @@ ) from crewai.a2a.errors import A2APollingTimeoutError -from crewai.a2a.task_helpers import TaskStateResult, process_task_state +from crewai.a2a.task_helpers import ( + ACTIONABLE_STATES, + TERMINAL_STATES, + TaskStateResult, + process_task_state, +) +from crewai.a2a.updates.base import PollingHandlerKwargs from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import ( A2APollingStartedEvent, @@ -28,15 +34,7 @@ from a2a.types import Task as A2ATask -TERMINAL_STATES = { - TaskState.completed, - TaskState.failed, - TaskState.rejected, - TaskState.canceled, -} - - -async def poll_task_until_complete( +async def _poll_task_until_complete( client: Client, task_id: str, polling_interval: float, @@ -85,7 +83,7 @@ async def poll_task_until_complete( if task.status.state in TERMINAL_STATES: return task - if task.status.state in {TaskState.input_required, TaskState.auth_required}: + if task.status.state in ACTIONABLE_STATES: return task if elapsed > polling_timeout: @@ -101,128 +99,123 @@ async def poll_task_until_complete( await asyncio.sleep(polling_interval) -async def execute_polling_delegation( - client: Client, - message: Message, - polling_interval: float, - polling_timeout: float, - endpoint: str, - agent_branch: Any | None, - turn_number: int, - is_multiturn: bool, - agent_role: str | None, - new_messages: list[Message], - agent_card: AgentCard, - history_length: int = 100, - max_polls: int | None = None, -) -> TaskStateResult: - """Execute A2A delegation using polling for updates. - - Args: - client: A2A client instance - message: Message to send - polling_interval: Seconds between poll attempts - polling_timeout: Max seconds before timeout - endpoint: A2A agent endpoint URL - agent_branch: Agent tree branch for logging - turn_number: Current turn number - is_multiturn: Whether this is a multi-turn conversation - agent_role: Agent role for logging - new_messages: List to collect messages - agent_card: The agent card - history_length: Number of messages to retrieve per poll - max_polls: Max number of poll attempts (None = unlimited) +class PollingHandler: + """Polling-based update handler.""" + + @staticmethod + async def execute( + client: Client, + message: Message, + new_messages: list[Message], + agent_card: AgentCard, + **kwargs: Unpack[PollingHandlerKwargs], + ) -> TaskStateResult: + """Execute A2A delegation using polling for updates. + + Args: + client: A2A client instance. + message: Message to send. + new_messages: List to collect messages. + agent_card: The agent card. + **kwargs: Polling-specific parameters. + + Returns: + Dictionary with status, result/error, and history. + """ + polling_interval = kwargs.get("polling_interval", 2.0) + polling_timeout = kwargs.get("polling_timeout", 300.0) + endpoint = kwargs.get("endpoint", "") + agent_branch = kwargs.get("agent_branch") + turn_number = kwargs.get("turn_number", 0) + is_multiturn = kwargs.get("is_multiturn", False) + agent_role = kwargs.get("agent_role") + history_length = kwargs.get("history_length", 100) + max_polls = kwargs.get("max_polls") + + task_id: str | None = None + + async for event in client.send_message(message): + if isinstance(event, Message): + new_messages.append(event) + result_parts = [ + part.root.text for part in event.parts if part.root.kind == "text" + ] + response_text = " ".join(result_parts) if result_parts else "" + + crewai_event_bus.emit( + None, + A2AResponseReceivedEvent( + response=response_text, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="completed", + agent_role=agent_role, + ), + ) - Returns: - Dictionary with status, result/error, and history - """ - task_id: str | None = None - - async for event in client.send_message(message): - if isinstance(event, Message): - new_messages.append(event) - result_parts = [ - part.root.text for part in event.parts if part.root.kind == "text" - ] - response_text = " ".join(result_parts) if result_parts else "" - - crewai_event_bus.emit( - None, - A2AResponseReceivedEvent( - response=response_text, - turn_number=turn_number, - is_multiturn=is_multiturn, - status="completed", - agent_role=agent_role, - ), - ) + return TaskStateResult( + status=TaskState.completed, + result=response_text, + history=new_messages, + agent_card=agent_card, + ) + if isinstance(event, tuple): + a2a_task, _ = event + task_id = a2a_task.id + + if a2a_task.status.state in TERMINAL_STATES | ACTIONABLE_STATES: + result = process_task_state( + a2a_task=a2a_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result + break + + if not task_id: return TaskStateResult( - status=TaskState.completed, - result=response_text, + status=TaskState.failed, + error="No task ID received from initial message", history=new_messages, - agent_card=agent_card, ) - if isinstance(event, tuple): - a2a_task, _ = event - task_id = a2a_task.id - - if a2a_task.status.state in TERMINAL_STATES | { - TaskState.input_required, - TaskState.auth_required, - }: - result = process_task_state( - a2a_task=a2a_task, - new_messages=new_messages, - agent_card=agent_card, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, - ) - if result: - return result - break + crewai_event_bus.emit( + agent_branch, + A2APollingStartedEvent( + task_id=task_id, + polling_interval=polling_interval, + endpoint=endpoint, + ), + ) + + final_task = await _poll_task_until_complete( + client=client, + task_id=task_id, + polling_interval=polling_interval, + polling_timeout=polling_timeout, + agent_branch=agent_branch, + history_length=history_length, + max_polls=max_polls, + ) + + result = process_task_state( + a2a_task=final_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result - if not task_id: return TaskStateResult( status=TaskState.failed, - error="No task ID received from initial message", + error=f"Unexpected task state: {final_task.status.state}", history=new_messages, ) - - crewai_event_bus.emit( - agent_branch, - A2APollingStartedEvent( - task_id=task_id, - polling_interval=polling_interval, - endpoint=endpoint, - ), - ) - - final_task = await poll_task_until_complete( - client=client, - task_id=task_id, - polling_interval=polling_interval, - polling_timeout=polling_timeout, - agent_branch=agent_branch, - history_length=history_length, - max_polls=max_polls, - ) - - result = process_task_state( - a2a_task=final_task, - new_messages=new_messages, - agent_card=agent_card, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, - ) - if result: - return result - - return TaskStateResult( - status=TaskState.failed, - error=f"Unexpected task state: {final_task.status.state}", - history=new_messages, - ) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py index 9c683ae980..03b2c6856b 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import AnyHttpUrl, BaseModel, Field from crewai.a2a.auth.schemas import AuthScheme @@ -17,7 +17,7 @@ class PushNotificationConfig(BaseModel): authentication: Auth scheme for the callback endpoint. """ - url: str = Field(description="Callback URL for push notifications") + url: AnyHttpUrl = Field(description="Callback URL for push notifications") id: str | None = Field(default=None, description="Unique config identifier") token: str | None = Field(default=None, description="Validation token") authentication: AuthScheme | None = Field( diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py index cff96bfaa3..712d7c3809 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py @@ -1,3 +1,40 @@ """Push notification (webhook) update mechanism handler.""" from __future__ import annotations + +from typing import Unpack + +from a2a.client import Client +from a2a.types import AgentCard, Message + +from crewai.a2a.task_helpers import TaskStateResult +from crewai.a2a.updates.base import PushNotificationHandlerKwargs + + +class PushNotificationHandler: + """Push notification (webhook) based update handler.""" + + @staticmethod + async def execute( + client: Client, + message: Message, + new_messages: list[Message], + agent_card: AgentCard, + **kwargs: Unpack[PushNotificationHandlerKwargs], + ) -> TaskStateResult: + """Execute A2A delegation using push notifications for updates. + + Args: + client: A2A client instance. + message: Message to send. + new_messages: List to collect messages. + agent_card: The agent card. + **kwargs: Push notification-specific parameters. + + Raises: + NotImplementedError: Push notifications not yet implemented. + """ + raise NotImplementedError( + "Push notification update mechanism is not yet implemented. " + "Use PollingConfig or StreamingConfig instead." + ) diff --git a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py index b453c687c9..74930e6a07 100644 --- a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Unpack import uuid from a2a.client import Client @@ -17,136 +18,126 @@ TextPart, ) -from crewai.a2a.task_helpers import TaskStateResult, process_task_state +from crewai.a2a.task_helpers import ( + ACTIONABLE_STATES, + TERMINAL_STATES, + TaskStateResult, + process_task_state, +) +from crewai.a2a.updates.base import StreamingHandlerKwargs from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import A2AResponseReceivedEvent -async def execute_streaming_delegation( - client: Client, - message: Message, - context_id: str | None, - task_id: str | None, - turn_number: int, - is_multiturn: bool, - agent_role: str | None, - new_messages: list[Message], - agent_card: AgentCard, -) -> TaskStateResult: - """Execute A2A delegation using SSE streaming for updates. - - Args: - client: A2A client instance - message: Message to send - context_id: Context ID for correlation - task_id: Task ID for correlation - turn_number: Current turn number - is_multiturn: Whether this is a multi-turn conversation - agent_role: Agent role for logging - new_messages: List to collect messages - agent_card: The agent card - - Returns: - Dictionary with status, result/error, and history - """ - result_parts: list[str] = [] - final_result: TaskStateResult | None = None - event_stream = client.send_message(message) - - try: - async for event in event_stream: - if isinstance(event, Message): - new_messages.append(event) - for part in event.parts: - if part.root.kind == "text": - text = part.root.text - result_parts.append(text) - - elif isinstance(event, tuple): - a2a_task, update = event - - if isinstance(update, TaskArtifactUpdateEvent): - artifact = update.artifact - result_parts.extend( - part.root.text - for part in artifact.parts - if part.root.kind == "text" +class StreamingHandler: + """SSE streaming-based update handler.""" + + @staticmethod + async def execute( + client: Client, + message: Message, + new_messages: list[Message], + agent_card: AgentCard, + **kwargs: Unpack[StreamingHandlerKwargs], + ) -> TaskStateResult: + """Execute A2A delegation using SSE streaming for updates. + + Args: + client: A2A client instance. + message: Message to send. + new_messages: List to collect messages. + agent_card: The agent card. + **kwargs: Streaming-specific parameters. + + Returns: + Dictionary with status, result/error, and history. + """ + context_id = kwargs.get("context_id") + task_id = kwargs.get("task_id") + turn_number = kwargs.get("turn_number", 0) + is_multiturn = kwargs.get("is_multiturn", False) + agent_role = kwargs.get("agent_role") + + result_parts: list[str] = [] + final_result: TaskStateResult | None = None + event_stream = client.send_message(message) + + try: + async for event in event_stream: + if isinstance(event, Message): + new_messages.append(event) + for part in event.parts: + if part.root.kind == "text": + text = part.root.text + result_parts.append(text) + + elif isinstance(event, tuple): + a2a_task, update = event + + if isinstance(update, TaskArtifactUpdateEvent): + artifact = update.artifact + result_parts.extend( + part.root.text + for part in artifact.parts + if part.root.kind == "text" + ) + + is_final_update = False + if isinstance(update, TaskStatusUpdateEvent): + is_final_update = update.final + + if ( + not is_final_update + and a2a_task.status.state + not in TERMINAL_STATES | ACTIONABLE_STATES + ): + continue + + final_result = process_task_state( + a2a_task=a2a_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + result_parts=result_parts, ) - - is_final_update = False - if isinstance(update, TaskStatusUpdateEvent): - is_final_update = update.final - - if not is_final_update and a2a_task.status.state not in [ - TaskState.completed, - TaskState.input_required, - TaskState.failed, - TaskState.rejected, - TaskState.auth_required, - TaskState.canceled, - ]: - continue - - final_result = process_task_state( - a2a_task=a2a_task, - new_messages=new_messages, - agent_card=agent_card, + if final_result: + break + + except A2AClientHTTPError as e: + error_msg = f"HTTP Error {e.status_code}: {e!s}" + + error_message = Message( + role=Role.agent, + message_id=str(uuid.uuid4()), + parts=[Part(root=TextPart(text=error_msg))], + context_id=context_id, + task_id=task_id, + ) + new_messages.append(error_message) + + crewai_event_bus.emit( + None, + A2AResponseReceivedEvent( + response=error_msg, turn_number=turn_number, is_multiturn=is_multiturn, + status="failed", agent_role=agent_role, - result_parts=result_parts, - ) - if final_result: - break - - except A2AClientHTTPError as e: - error_msg = f"HTTP Error {e.status_code}: {e!s}" - - error_message = Message( - role=Role.agent, - message_id=str(uuid.uuid4()), - parts=[Part(root=TextPart(text=error_msg))], - context_id=context_id, - task_id=task_id, - ) - new_messages.append(error_message) - - crewai_event_bus.emit( - None, - A2AResponseReceivedEvent( - response=error_msg, - turn_number=turn_number, - is_multiturn=is_multiturn, - status="failed", - agent_role=agent_role, - ), - ) + ), + ) + return TaskStateResult( + status=TaskState.failed, + error=error_msg, + history=new_messages, + ) + + if final_result: + return final_result + return TaskStateResult( - status=TaskState.failed, - error=error_msg, + status=TaskState.completed, + result=" ".join(result_parts) if result_parts else "", history=new_messages, ) - - except Exception as e: - current_exception: Exception | BaseException | None = e - while current_exception: - if hasattr(current_exception, "response"): - response = current_exception.response - if hasattr(response, "text"): - break - if current_exception and hasattr(current_exception, "__cause__"): - current_exception = current_exception.__cause__ - raise - - finally: - if hasattr(event_stream, "aclose"): - await event_stream.aclose() - - if final_result: - return final_result - - return TaskStateResult( - status=TaskState.completed, - result=" ".join(result_parts) if result_parts else "", - history=new_messages, - ) diff --git a/lib/crewai/src/crewai/a2a/utils.py b/lib/crewai/src/crewai/a2a/utils.py index 01807ce30b..933f77b664 100644 --- a/lib/crewai/src/crewai/a2a/utils.py +++ b/lib/crewai/src/crewai/a2a/utils.py @@ -34,9 +34,15 @@ from crewai.a2a.config import A2AConfig from crewai.a2a.task_helpers import TaskStateResult from crewai.a2a.types import PartsDict, PartsMetadataDict -from crewai.a2a.updates import PollingConfig, UpdateConfig -from crewai.a2a.updates.polling.handler import execute_polling_delegation -from crewai.a2a.updates.streaming.handler import execute_streaming_delegation +from crewai.a2a.updates import ( + PollingConfig, + PollingHandler, + PushNotificationConfig, + PushNotificationHandler, + StreamingConfig, + StreamingHandler, + UpdateConfig, +) from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import ( A2AConversationStartedEvent, @@ -53,6 +59,31 @@ from crewai.a2a.auth.schemas import AuthScheme +HandlerType = ( + type[PollingHandler] | type[StreamingHandler] | type[PushNotificationHandler] +) + +HANDLER_REGISTRY: dict[type[UpdateConfig], HandlerType] = { + PollingConfig: PollingHandler, + StreamingConfig: StreamingHandler, + PushNotificationConfig: PushNotificationHandler, +} + + +def get_handler(config: UpdateConfig | None) -> HandlerType: + """Get the handler class for a given update config. + + Args: + config: Update mechanism configuration. + + Returns: + Handler class for the config type, defaults to StreamingHandler. + """ + if config is None: + return StreamingHandler + return HANDLER_REGISTRY.get(type(config), StreamingHandler) + + @lru_cache() def _fetch_agent_card_cached( endpoint: str, @@ -448,14 +479,28 @@ async def _execute_a2a_delegation_async( ), ) - polling_config = updates if isinstance(updates, PollingConfig) else None - use_polling = polling_config is not None - polling_interval = polling_config.interval if polling_config else 2.0 - effective_polling_timeout = ( - polling_config.timeout - if polling_config and polling_config.timeout - else float(timeout) - ) + handler = get_handler(updates) + use_polling = isinstance(updates, PollingConfig) + + handler_kwargs: dict[str, Any] = { + "turn_number": turn_number, + "is_multiturn": is_multiturn, + "agent_role": agent_role, + "context_id": context_id, + "task_id": task_id, + "endpoint": endpoint, + "agent_branch": agent_branch, + } + + if isinstance(updates, PollingConfig): + handler_kwargs.update( + { + "polling_interval": updates.interval, + "polling_timeout": updates.timeout or float(timeout), + "history_length": updates.history_length, + "max_polls": updates.max_polls, + } + ) async with _create_a2a_client( agent_card=agent_card, @@ -466,33 +511,12 @@ async def _execute_a2a_delegation_async( auth=auth, use_polling=use_polling, ) as client: - if use_polling and polling_config: - return await execute_polling_delegation( - client=client, - message=message, - polling_interval=polling_interval, - polling_timeout=effective_polling_timeout, - endpoint=endpoint, - agent_branch=agent_branch, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, - new_messages=new_messages, - agent_card=agent_card, - history_length=polling_config.history_length, - max_polls=polling_config.max_polls, - ) - - return await execute_streaming_delegation( + return await handler.execute( client=client, message=message, - context_id=context_id, - task_id=task_id, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, new_messages=new_messages, agent_card=agent_card, + **handler_kwargs, ) From 8c089636e021b06d7bacdfab66a17bf53332387c Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 20:11:13 -0500 Subject: [PATCH 07/34] chore: use python version compat types --- lib/crewai/src/crewai/a2a/updates/polling/handler.py | 3 ++- lib/crewai/src/crewai/a2a/updates/streaming/handler.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/updates/polling/handler.py b/lib/crewai/src/crewai/a2a/updates/polling/handler.py index 453affe210..84d41afcfa 100644 --- a/lib/crewai/src/crewai/a2a/updates/polling/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/polling/handler.py @@ -4,7 +4,7 @@ import asyncio import time -from typing import TYPE_CHECKING, Any, Unpack +from typing import TYPE_CHECKING, Any from a2a.client import Client from a2a.types import ( @@ -13,6 +13,7 @@ TaskQueryParams, TaskState, ) +from typing_extensions import Unpack from crewai.a2a.errors import A2APollingTimeoutError from crewai.a2a.task_helpers import ( diff --git a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py index 74930e6a07..eb05724e7b 100644 --- a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py @@ -2,7 +2,6 @@ from __future__ import annotations -from typing import Unpack import uuid from a2a.client import Client @@ -17,6 +16,7 @@ TaskStatusUpdateEvent, TextPart, ) +from typing_extensions import Unpack from crewai.a2a.task_helpers import ( ACTIONABLE_STATES, From 174c61bd736b693a5785e597838bc9c94424e213 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 22:05:44 -0500 Subject: [PATCH 08/34] feat: add push notification protocol and config --- lib/crewai/src/crewai/a2a/updates/__init__.py | 2 + lib/crewai/src/crewai/a2a/updates/base.py | 66 ++++++++++++++++++- .../a2a/updates/push_notifications/config.py | 13 ++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/lib/crewai/src/crewai/a2a/updates/__init__.py b/lib/crewai/src/crewai/a2a/updates/__init__.py index 79b7f35b45..953eb48c3e 100644 --- a/lib/crewai/src/crewai/a2a/updates/__init__.py +++ b/lib/crewai/src/crewai/a2a/updates/__init__.py @@ -4,6 +4,7 @@ BaseHandlerKwargs, PollingHandlerKwargs, PushNotificationHandlerKwargs, + PushNotificationResultStore, StreamingHandlerKwargs, UpdateHandler, ) @@ -25,6 +26,7 @@ "PushNotificationConfig", "PushNotificationHandler", "PushNotificationHandlerKwargs", + "PushNotificationResultStore", "StreamingConfig", "StreamingHandler", "StreamingHandlerKwargs", diff --git a/lib/crewai/src/crewai/a2a/updates/base.py b/lib/crewai/src/crewai/a2a/updates/base.py index 34016f955d..74060a1d39 100644 --- a/lib/crewai/src/crewai/a2a/updates/base.py +++ b/lib/crewai/src/crewai/a2a/updates/base.py @@ -4,12 +4,16 @@ from typing import TYPE_CHECKING, Any, Protocol, TypedDict +from pydantic import GetCoreSchemaHandler +from pydantic_core import CoreSchema, core_schema + if TYPE_CHECKING: from a2a.client import Client - from a2a.types import AgentCard, Message + from a2a.types import AgentCard, Message, Task from crewai.a2a.task_helpers import TaskStateResult + from crewai.a2a.updates.push_notifications.config import PushNotificationConfig class BaseHandlerKwargs(TypedDict, total=False): @@ -41,6 +45,66 @@ class StreamingHandlerKwargs(BaseHandlerKwargs, total=False): class PushNotificationHandlerKwargs(BaseHandlerKwargs, total=False): """Kwargs for push notification handler.""" + config: PushNotificationConfig + result_store: PushNotificationResultStore + polling_timeout: float + polling_interval: float + agent_branch: Any + + +class PushNotificationResultStore(Protocol): + """Protocol for storing and retrieving push notification results. + + This protocol defines the interface for a result store that the + PushNotificationHandler uses to wait for task completion. Enterprise + implementations can use Redis, in-memory storage, or other backends. + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + return core_schema.any_schema() + + async def wait_for_result( + self, + task_id: str, + timeout: float, + poll_interval: float = 1.0, + ) -> Task | None: + """Wait for a task result to be available. + + Args: + task_id: The task ID to wait for. + timeout: Max seconds to wait before returning None. + poll_interval: Seconds between polling attempts. + + Returns: + The completed Task object, or None if timeout. + """ + ... + + async def get_result(self, task_id: str) -> Task | None: + """Get a task result if available. + + Args: + task_id: The task ID to retrieve. + + Returns: + The Task object if available, None otherwise. + """ + ... + + async def store_result(self, task: Task) -> None: + """Store a task result. + + Args: + task: The Task object to store. + """ + ... + class UpdateHandler(Protocol): """Protocol for A2A update mechanism handlers.""" diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py index 03b2c6856b..b37ed67d34 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py @@ -5,6 +5,7 @@ from pydantic import AnyHttpUrl, BaseModel, Field from crewai.a2a.auth.schemas import AuthScheme +from crewai.a2a.updates.base import PushNotificationResultStore class PushNotificationConfig(BaseModel): @@ -15,6 +16,9 @@ class PushNotificationConfig(BaseModel): id: Unique identifier for this config. token: Token to validate incoming notifications. authentication: Auth scheme for the callback endpoint. + timeout: Max seconds to wait for task completion. + interval: Seconds between result polling attempts. + result_store: Store for receiving push notification results. """ url: AnyHttpUrl = Field(description="Callback URL for push notifications") @@ -23,3 +27,12 @@ class PushNotificationConfig(BaseModel): authentication: AuthScheme | None = Field( default=None, description="Authentication for callback endpoint" ) + timeout: float | None = Field( + default=300.0, gt=0, description="Max seconds to wait for task completion" + ) + interval: float = Field( + default=2.0, gt=0, description="Seconds between result polling attempts" + ) + result_store: PushNotificationResultStore | None = Field( + default=None, description="Result store for push notification handling" + ) From 3607993e7e20996d59f469c4ecd730a83956307f Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 22:06:20 -0500 Subject: [PATCH 09/34] feat: add push notification events --- .../src/crewai/events/types/a2a_events.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/lib/crewai/src/crewai/events/types/a2a_events.py b/lib/crewai/src/crewai/events/types/a2a_events.py index 6afd1533d2..87eb6040b4 100644 --- a/lib/crewai/src/crewai/events/types/a2a_events.py +++ b/lib/crewai/src/crewai/events/types/a2a_events.py @@ -171,3 +171,42 @@ class A2APollingStatusEvent(A2AEventBase): state: str elapsed_seconds: float poll_count: int + + +class A2APushNotificationRegisteredEvent(A2AEventBase): + """Event emitted when push notification callback is registered. + + Attributes: + task_id: A2A task ID for which callback is registered + callback_url: URL where agent will send push notifications + """ + + type: str = "a2a_push_notification_registered" + task_id: str + callback_url: str + + +class A2APushNotificationReceivedEvent(A2AEventBase): + """Event emitted when a push notification is received. + + Attributes: + task_id: A2A task ID from the notification + state: Current task state from the notification + """ + + type: str = "a2a_push_notification_received" + task_id: str + state: str + + +class A2APushNotificationTimeoutEvent(A2AEventBase): + """Event emitted when push notification wait times out. + + Attributes: + task_id: A2A task ID that timed out + timeout_seconds: Timeout duration in seconds + """ + + type: str = "a2a_push_notification_timeout" + task_id: str + timeout_seconds: float From 33d73c0be19a0ca05eea8248b55cd9804c528ca6 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 22:26:03 -0500 Subject: [PATCH 10/34] refactor: extract shared message sending logic --- lib/crewai/src/crewai/a2a/task_helpers.py | 92 ++++++++++++++++++- .../src/crewai/a2a/updates/polling/handler.py | 71 +++----------- 2 files changed, 106 insertions(+), 57 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/task_helpers.py b/lib/crewai/src/crewai/a2a/task_helpers.py index 2d34b3c406..5352d7eb69 100644 --- a/lib/crewai/src/crewai/a2a/task_helpers.py +++ b/lib/crewai/src/crewai/a2a/task_helpers.py @@ -2,10 +2,21 @@ from __future__ import annotations +from collections.abc import AsyncIterator from typing import TYPE_CHECKING, NotRequired, TypedDict import uuid -from a2a.types import AgentCard, Message, Part, Role, TaskState, TextPart +from a2a.types import ( + AgentCard, + Message, + Part, + Role, + Task, + TaskArtifactUpdateEvent, + TaskState, + TaskStatusUpdateEvent, + TextPart, +) from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import A2AResponseReceivedEvent @@ -14,6 +25,10 @@ if TYPE_CHECKING: from a2a.types import Task as A2ATask +SendMessageEvent = ( + tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None] | Message +) + TERMINAL_STATES: frozenset[TaskState] = frozenset( { @@ -221,3 +236,78 @@ def process_task_state( ) return None + + +async def send_message_and_get_task_id( + event_stream: AsyncIterator[SendMessageEvent], + new_messages: list[Message], + agent_card: AgentCard, + turn_number: int, + is_multiturn: bool, + agent_role: str | None, +) -> str | TaskStateResult: + """Send message and process initial response. + + Handles the common pattern of sending a message and either: + - Getting an immediate Message response (task completed synchronously) + - Getting a Task that needs polling/waiting for completion + + Args: + event_stream: Async iterator from client.send_message() + new_messages: List to collect messages (modified in place) + agent_card: The agent card + turn_number: Current turn number + is_multiturn: Whether multi-turn conversation + agent_role: Agent role for logging + + Returns: + Task ID string if agent needs polling/waiting, or TaskStateResult if done. + """ + async for event in event_stream: + if isinstance(event, Message): + new_messages.append(event) + result_parts = [ + part.root.text for part in event.parts if part.root.kind == "text" + ] + response_text = " ".join(result_parts) if result_parts else "" + + crewai_event_bus.emit( + None, + A2AResponseReceivedEvent( + response=response_text, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="completed", + agent_role=agent_role, + ), + ) + + return TaskStateResult( + status=TaskState.completed, + result=response_text, + history=new_messages, + agent_card=agent_card, + ) + + if isinstance(event, tuple): + a2a_task, _ = event + + if a2a_task.status.state in TERMINAL_STATES | ACTIONABLE_STATES: + result = process_task_state( + a2a_task=a2a_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result + + return a2a_task.id + + return TaskStateResult( + status=TaskState.failed, + error="No task ID received from initial message", + history=new_messages, + ) diff --git a/lib/crewai/src/crewai/a2a/updates/polling/handler.py b/lib/crewai/src/crewai/a2a/updates/polling/handler.py index 84d41afcfa..753a097306 100644 --- a/lib/crewai/src/crewai/a2a/updates/polling/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/polling/handler.py @@ -21,13 +21,13 @@ TERMINAL_STATES, TaskStateResult, process_task_state, + send_message_and_get_task_id, ) from crewai.a2a.updates.base import PollingHandlerKwargs from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import ( A2APollingStartedEvent, A2APollingStatusEvent, - A2AResponseReceivedEvent, ) @@ -81,10 +81,7 @@ async def _poll_task_until_complete( ), ) - if task.status.state in TERMINAL_STATES: - return task - - if task.status.state in ACTIONABLE_STATES: + if task.status.state in TERMINAL_STATES | ACTIONABLE_STATES: return task if elapsed > polling_timeout: @@ -133,57 +130,19 @@ async def execute( history_length = kwargs.get("history_length", 100) max_polls = kwargs.get("max_polls") - task_id: str | None = None - - async for event in client.send_message(message): - if isinstance(event, Message): - new_messages.append(event) - result_parts = [ - part.root.text for part in event.parts if part.root.kind == "text" - ] - response_text = " ".join(result_parts) if result_parts else "" - - crewai_event_bus.emit( - None, - A2AResponseReceivedEvent( - response=response_text, - turn_number=turn_number, - is_multiturn=is_multiturn, - status="completed", - agent_role=agent_role, - ), - ) - - return TaskStateResult( - status=TaskState.completed, - result=response_text, - history=new_messages, - agent_card=agent_card, - ) - - if isinstance(event, tuple): - a2a_task, _ = event - task_id = a2a_task.id - - if a2a_task.status.state in TERMINAL_STATES | ACTIONABLE_STATES: - result = process_task_state( - a2a_task=a2a_task, - new_messages=new_messages, - agent_card=agent_card, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, - ) - if result: - return result - break - - if not task_id: - return TaskStateResult( - status=TaskState.failed, - error="No task ID received from initial message", - history=new_messages, - ) + result_or_task_id = await send_message_and_get_task_id( + event_stream=client.send_message(message), + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + + if not isinstance(result_or_task_id, str): + return result_or_task_id + + task_id = result_or_task_id crewai_event_bus.emit( agent_branch, From f9977a5ebe9659b5b1f7452afe6c8353b140e24b Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 22:27:13 -0500 Subject: [PATCH 11/34] feat: implement push notification handler --- .../a2a/updates/push_notifications/handler.py | 188 +++++++++++++++++- lib/crewai/src/crewai/a2a/utils.py | 9 + 2 files changed, 189 insertions(+), 8 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py index 712d7c3809..8217cf391a 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py @@ -2,13 +2,96 @@ from __future__ import annotations -from typing import Unpack +import logging +from typing import TYPE_CHECKING, Any from a2a.client import Client -from a2a.types import AgentCard, Message +from a2a.types import ( + AgentCard, + Message, + PushNotificationConfig as A2APushNotificationConfig, + TaskPushNotificationConfig, + TaskState, +) +from typing_extensions import Unpack -from crewai.a2a.task_helpers import TaskStateResult -from crewai.a2a.updates.base import PushNotificationHandlerKwargs +from crewai.a2a.task_helpers import ( + TaskStateResult, + process_task_state, + send_message_and_get_task_id, +) +from crewai.a2a.updates.base import ( + PushNotificationHandlerKwargs, + PushNotificationResultStore, +) +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.a2a_events import ( + A2APushNotificationRegisteredEvent, + A2APushNotificationTimeoutEvent, +) + + +if TYPE_CHECKING: + from a2a.types import Task as A2ATask + + from crewai.a2a.updates.push_notifications.config import PushNotificationConfig + + +logger = logging.getLogger(__name__) + + +def _build_a2a_push_config(config: PushNotificationConfig) -> A2APushNotificationConfig: + """Convert our config to A2A SDK's PushNotificationConfig. + + Args: + config: Our PushNotificationConfig. + + Returns: + A2A SDK PushNotificationConfig. + """ + return A2APushNotificationConfig( + url=str(config.url), + id=config.id, + token=config.token, + authentication=None, + ) + + +async def _wait_for_push_result( + task_id: str, + result_store: PushNotificationResultStore, + timeout: float, + poll_interval: float, + agent_branch: Any | None = None, +) -> A2ATask | None: + """Wait for push notification result. + + Args: + task_id: Task ID to wait for. + result_store: Store to retrieve results from. + timeout: Max seconds to wait. + poll_interval: Seconds between polling attempts. + agent_branch: Agent tree branch for logging. + + Returns: + Final task object, or None if timeout. + """ + task = await result_store.wait_for_result( + task_id=task_id, + timeout=timeout, + poll_interval=poll_interval, + ) + + if task is None: + crewai_event_bus.emit( + agent_branch, + A2APushNotificationTimeoutEvent( + task_id=task_id, + timeout_seconds=timeout, + ), + ) + + return task class PushNotificationHandler: @@ -31,10 +114,99 @@ async def execute( agent_card: The agent card. **kwargs: Push notification-specific parameters. + Returns: + Dictionary with status, result/error, and history. + Raises: - NotImplementedError: Push notifications not yet implemented. + ValueError: If result_store or config not provided. """ - raise NotImplementedError( - "Push notification update mechanism is not yet implemented. " - "Use PollingConfig or StreamingConfig instead." + config = kwargs.get("config") + result_store = kwargs.get("result_store") + polling_timeout = kwargs.get("polling_timeout", 300.0) + polling_interval = kwargs.get("polling_interval", 2.0) + agent_branch = kwargs.get("agent_branch") + turn_number = kwargs.get("turn_number", 0) + is_multiturn = kwargs.get("is_multiturn", False) + agent_role = kwargs.get("agent_role") + + if config is None: + return TaskStateResult( + status=TaskState.failed, + error="PushNotificationConfig is required for push notification handler", + history=new_messages, + ) + + if result_store is None: + return TaskStateResult( + status=TaskState.failed, + error="PushNotificationResultStore is required for push notification handler", + history=new_messages, + ) + + result_or_task_id = await send_message_and_get_task_id( + event_stream=client.send_message(message), + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + + if not isinstance(result_or_task_id, str): + return result_or_task_id + + task_id = result_or_task_id + + a2a_push_config = _build_a2a_push_config(config) + await client.set_task_callback( + TaskPushNotificationConfig( + task_id=task_id, + push_notification_config=a2a_push_config, + ) + ) + + crewai_event_bus.emit( + agent_branch, + A2APushNotificationRegisteredEvent( + task_id=task_id, + callback_url=str(config.url), + ), + ) + + logger.debug( + "Registered push notification callback for task %s at %s", + task_id, + config.url, + ) + + final_task = await _wait_for_push_result( + task_id=task_id, + result_store=result_store, + timeout=polling_timeout, + poll_interval=polling_interval, + agent_branch=agent_branch, + ) + + if final_task is None: + return TaskStateResult( + status=TaskState.failed, + error=f"Push notification timeout after {polling_timeout}s", + history=new_messages, + ) + + result = process_task_state( + a2a_task=final_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result + + return TaskStateResult( + status=TaskState.failed, + error=f"Unexpected task state: {final_task.status.state}", + history=new_messages, ) diff --git a/lib/crewai/src/crewai/a2a/utils.py b/lib/crewai/src/crewai/a2a/utils.py index 933f77b664..1b0ac38081 100644 --- a/lib/crewai/src/crewai/a2a/utils.py +++ b/lib/crewai/src/crewai/a2a/utils.py @@ -501,6 +501,15 @@ async def _execute_a2a_delegation_async( "max_polls": updates.max_polls, } ) + elif isinstance(updates, PushNotificationConfig): + handler_kwargs.update( + { + "config": updates, + "result_store": updates.result_store, + "polling_timeout": updates.timeout or float(timeout), + "polling_interval": updates.interval, + } + ) async with _create_a2a_client( agent_card=agent_card, From 1f9fe0f68eb300ee8591bb95d9943955841f09dd Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 22:37:02 -0500 Subject: [PATCH 12/34] chore: remove excess docs note --- lib/crewai/src/crewai/a2a/updates/base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/updates/base.py b/lib/crewai/src/crewai/a2a/updates/base.py index 74060a1d39..a1d8598375 100644 --- a/lib/crewai/src/crewai/a2a/updates/base.py +++ b/lib/crewai/src/crewai/a2a/updates/base.py @@ -56,8 +56,7 @@ class PushNotificationResultStore(Protocol): """Protocol for storing and retrieving push notification results. This protocol defines the interface for a result store that the - PushNotificationHandler uses to wait for task completion. Enterprise - implementations can use Redis, in-memory storage, or other backends. + PushNotificationHandler uses to wait for task completion. """ @classmethod From a2e69018925d80f3383c3abb4aefdace638ce8ee Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 5 Jan 2026 22:50:27 -0500 Subject: [PATCH 13/34] chore: remove unused authentication field from PushNotificationConfig --- .../src/crewai/a2a/updates/push_notifications/config.py | 5 ----- .../src/crewai/a2a/updates/push_notifications/handler.py | 1 - 2 files changed, 6 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py index b37ed67d34..c354dd1407 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py @@ -4,7 +4,6 @@ from pydantic import AnyHttpUrl, BaseModel, Field -from crewai.a2a.auth.schemas import AuthScheme from crewai.a2a.updates.base import PushNotificationResultStore @@ -15,7 +14,6 @@ class PushNotificationConfig(BaseModel): url: Callback URL where agent sends push notifications. id: Unique identifier for this config. token: Token to validate incoming notifications. - authentication: Auth scheme for the callback endpoint. timeout: Max seconds to wait for task completion. interval: Seconds between result polling attempts. result_store: Store for receiving push notification results. @@ -24,9 +22,6 @@ class PushNotificationConfig(BaseModel): url: AnyHttpUrl = Field(description="Callback URL for push notifications") id: str | None = Field(default=None, description="Unique config identifier") token: str | None = Field(default=None, description="Validation token") - authentication: AuthScheme | None = Field( - default=None, description="Authentication for callback endpoint" - ) timeout: float | None = Field( default=300.0, gt=0, description="Max seconds to wait for task completion" ) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py index 8217cf391a..933797aee1 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py @@ -53,7 +53,6 @@ def _build_a2a_push_config(config: PushNotificationConfig) -> A2APushNotificatio url=str(config.url), id=config.id, token=config.token, - authentication=None, ) From e9fafd41b53e169bfcee7c4d0787c2a217ee1222 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 00:06:25 -0500 Subject: [PATCH 14/34] feat: add authentication field to PushNotificationConfig --- .../src/crewai/a2a/updates/push_notifications/config.py | 5 +++++ .../src/crewai/a2a/updates/push_notifications/handler.py | 1 + 2 files changed, 6 insertions(+) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py index c354dd1407..2cd22bc214 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from a2a.types import PushNotificationAuthenticationInfo from pydantic import AnyHttpUrl, BaseModel, Field from crewai.a2a.updates.base import PushNotificationResultStore @@ -14,6 +15,7 @@ class PushNotificationConfig(BaseModel): url: Callback URL where agent sends push notifications. id: Unique identifier for this config. token: Token to validate incoming notifications. + authentication: Auth info for agent to use when calling webhook. timeout: Max seconds to wait for task completion. interval: Seconds between result polling attempts. result_store: Store for receiving push notification results. @@ -22,6 +24,9 @@ class PushNotificationConfig(BaseModel): url: AnyHttpUrl = Field(description="Callback URL for push notifications") id: str | None = Field(default=None, description="Unique config identifier") token: str | None = Field(default=None, description="Validation token") + authentication: PushNotificationAuthenticationInfo | None = Field( + default=None, description="Auth info for agent to use when calling webhook" + ) timeout: float | None = Field( default=300.0, gt=0, description="Max seconds to wait for task completion" ) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py index 933797aee1..1283f1e8e6 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py @@ -53,6 +53,7 @@ def _build_a2a_push_config(config: PushNotificationConfig) -> A2APushNotificatio url=str(config.url), id=config.id, token=config.token, + authentication=config.authentication, ) From edbe8a80235a4c6967af0f0200e6efedb8e67445 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 00:21:16 -0500 Subject: [PATCH 15/34] chore: update typing import for python version compat --- lib/crewai/src/crewai/a2a/task_helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/crewai/src/crewai/a2a/task_helpers.py b/lib/crewai/src/crewai/a2a/task_helpers.py index 5352d7eb69..1083f3fea1 100644 --- a/lib/crewai/src/crewai/a2a/task_helpers.py +++ b/lib/crewai/src/crewai/a2a/task_helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, NotRequired, TypedDict +from typing import TYPE_CHECKING, TypedDict import uuid from a2a.types import ( @@ -17,6 +17,7 @@ TaskStatusUpdateEvent, TextPart, ) +from typing_extensions import NotRequired from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import A2AResponseReceivedEvent From d6c7f33744a73e924335c71f43085f233c3c2d32 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 09:47:37 -0500 Subject: [PATCH 16/34] chore: add a2a integration tests with cassettes --- conftest.py | 2 + lib/crewai/tests/a2a/test_a2a_integration.py | 326 ++++++++++++++++++ ...entCardFetching.test_fetch_agent_card.yaml | 44 +++ ...tegration.test_polling_completes_task.yaml | 126 +++++++ ...gration.test_streaming_completes_task.yaml | 90 +++++ ...ns.test_send_message_and_get_response.yaml | 90 +++++ 6 files changed, 678 insertions(+) create mode 100644 lib/crewai/tests/a2a/test_a2a_integration.py create mode 100644 lib/crewai/tests/cassettes/a2a/TestA2AAgentCardFetching.test_fetch_agent_card.yaml create mode 100644 lib/crewai/tests/cassettes/a2a/TestA2APollingIntegration.test_polling_completes_task.yaml create mode 100644 lib/crewai/tests/cassettes/a2a/TestA2AStreamingIntegration.test_streaming_completes_task.yaml create mode 100644 lib/crewai/tests/cassettes/a2a/TestA2ATaskOperations.test_send_message_and_get_response.yaml diff --git a/conftest.py b/conftest.py index 3377bae81a..d63e7c8859 100644 --- a/conftest.py +++ b/conftest.py @@ -120,6 +120,8 @@ def setup_test_environment() -> Generator[None, Any, None]: "accept-encoding": "ACCEPT-ENCODING-XXX", "x-amzn-requestid": "X-AMZN-REQUESTID-XXX", "x-amzn-RequestId": "X-AMZN-REQUESTID-XXX", + "x-a2a-notification-token": "X-A2A-NOTIFICATION-TOKEN-XXX", + "x-a2a-version": "X-A2A-VERSION-XXX", } diff --git a/lib/crewai/tests/a2a/test_a2a_integration.py b/lib/crewai/tests/a2a/test_a2a_integration.py new file mode 100644 index 0000000000..6aa8906892 --- /dev/null +++ b/lib/crewai/tests/a2a/test_a2a_integration.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import os +import uuid + +import pytest +import pytest_asyncio + +from a2a.client import ClientFactory +from a2a.types import AgentCard, Message, Part, Role, TaskState, TextPart + +from crewai.a2a.updates.polling.handler import PollingHandler +from crewai.a2a.updates.streaming.handler import StreamingHandler + + +A2A_TEST_ENDPOINT = os.getenv("A2A_TEST_ENDPOINT", "http://localhost:9999") + + +@pytest_asyncio.fixture +async def a2a_client(): + """Create A2A client for test server.""" + client = await ClientFactory.connect(A2A_TEST_ENDPOINT) + yield client + await client.close() + + +@pytest.fixture +def test_message() -> Message: + """Create a simple test message.""" + return Message( + role=Role.user, + parts=[Part(root=TextPart(text="What is 2 + 2?"))], + message_id=str(uuid.uuid4()), + ) + + +@pytest_asyncio.fixture +async def agent_card(a2a_client) -> AgentCard: + """Fetch the real agent card from the server.""" + return await a2a_client.get_card() + + +class TestA2AAgentCardFetching: + """Integration tests for agent card fetching.""" + + @pytest.mark.vcr() + @pytest.mark.asyncio + async def test_fetch_agent_card(self, a2a_client) -> None: + """Test fetching an agent card from the server.""" + card = await a2a_client.get_card() + + assert card is not None + assert card.name == "GPT Assistant" + assert card.url is not None + assert card.capabilities is not None + assert card.capabilities.streaming is True + + +class TestA2APollingIntegration: + """Integration tests for A2A polling handler.""" + + @pytest.mark.vcr() + @pytest.mark.asyncio + async def test_polling_completes_task( + self, + a2a_client, + test_message: Message, + agent_card: AgentCard, + ) -> None: + """Test that polling handler completes a task successfully.""" + new_messages: list[Message] = [] + + result = await PollingHandler.execute( + client=a2a_client, + message=test_message, + new_messages=new_messages, + agent_card=agent_card, + polling_interval=0.5, + polling_timeout=30.0, + ) + + assert isinstance(result, dict) + assert result["status"] == TaskState.completed + assert result.get("result") is not None + assert "4" in result["result"] + + +class TestA2AStreamingIntegration: + """Integration tests for A2A streaming handler.""" + + @pytest.mark.vcr() + @pytest.mark.asyncio + async def test_streaming_completes_task( + self, + a2a_client, + test_message: Message, + agent_card: AgentCard, + ) -> None: + """Test that streaming handler completes a task successfully.""" + new_messages: list[Message] = [] + + result = await StreamingHandler.execute( + client=a2a_client, + message=test_message, + new_messages=new_messages, + agent_card=agent_card, + ) + + assert isinstance(result, dict) + assert result["status"] == TaskState.completed + assert result.get("result") is not None + + +class TestA2ATaskOperations: + """Integration tests for task operations.""" + + @pytest.mark.vcr() + @pytest.mark.asyncio + async def test_send_message_and_get_response( + self, + a2a_client, + test_message: Message, + ) -> None: + """Test sending a message and getting a response.""" + from a2a.types import Task + + final_task: Task | None = None + async for event in a2a_client.send_message(test_message): + if isinstance(event, tuple) and len(event) >= 1: + task, _ = event + if isinstance(task, Task): + final_task = task + + assert final_task is not None + assert final_task.id is not None + assert final_task.status is not None + assert final_task.status.state == TaskState.completed + + +class TestA2APushNotificationHandler: + """Tests for push notification handler. + + These tests use mocks for the result store since webhook callbacks + are incoming requests that can't be recorded with VCR. + """ + + @pytest.fixture + def mock_agent_card(self) -> AgentCard: + """Create a minimal valid agent card for testing.""" + from a2a.types import AgentCapabilities + + return AgentCard( + name="Test Agent", + description="Test agent for push notification tests", + url="http://localhost:9999", + version="1.0.0", + capabilities=AgentCapabilities(streaming=True, push_notifications=True), + default_input_modes=["text"], + default_output_modes=["text"], + skills=[], + ) + + @pytest.fixture + def mock_task(self) -> "Task": + """Create a minimal valid task for testing.""" + from a2a.types import Task, TaskStatus + + return Task( + id="task-123", + context_id="ctx-123", + status=TaskStatus(state=TaskState.working), + ) + + @pytest.mark.asyncio + async def test_push_handler_registers_callback_and_waits( + self, + mock_agent_card: AgentCard, + mock_task, + ) -> None: + """Test that push handler registers callback and waits for result.""" + from unittest.mock import AsyncMock, MagicMock + + from a2a.types import Task, TaskStatus + from pydantic import AnyHttpUrl + + from crewai.a2a.updates.push_notifications.config import PushNotificationConfig + from crewai.a2a.updates.push_notifications.handler import PushNotificationHandler + + completed_task = Task( + id="task-123", + context_id="ctx-123", + status=TaskStatus(state=TaskState.completed), + history=[], + ) + + mock_store = MagicMock() + mock_store.wait_for_result = AsyncMock(return_value=completed_task) + + async def mock_send_message(*args, **kwargs): + yield (mock_task, None) + + mock_client = MagicMock() + mock_client.send_message = mock_send_message + mock_client.set_task_callback = AsyncMock() + + config = PushNotificationConfig( + url=AnyHttpUrl("http://localhost:8080/a2a/callback"), + token="secret-token", + result_store=mock_store, + ) + + test_msg = Message( + role=Role.user, + parts=[Part(root=TextPart(text="What is 2+2?"))], + message_id="msg-001", + ) + + new_messages: list[Message] = [] + + result = await PushNotificationHandler.execute( + client=mock_client, + message=test_msg, + new_messages=new_messages, + agent_card=mock_agent_card, + config=config, + result_store=mock_store, + polling_timeout=30.0, + polling_interval=1.0, + ) + + mock_client.set_task_callback.assert_called_once() + mock_store.wait_for_result.assert_called_once_with( + task_id="task-123", + timeout=30.0, + poll_interval=1.0, + ) + + assert result["status"] == TaskState.completed + + @pytest.mark.asyncio + async def test_push_handler_returns_failure_on_timeout( + self, + mock_agent_card: AgentCard, + ) -> None: + """Test that push handler returns failure when result store times out.""" + from unittest.mock import AsyncMock, MagicMock + + from a2a.types import Task, TaskStatus + from pydantic import AnyHttpUrl + + from crewai.a2a.updates.push_notifications.config import PushNotificationConfig + from crewai.a2a.updates.push_notifications.handler import PushNotificationHandler + + mock_store = MagicMock() + mock_store.wait_for_result = AsyncMock(return_value=None) + + working_task = Task( + id="task-456", + context_id="ctx-456", + status=TaskStatus(state=TaskState.working), + ) + + async def mock_send_message(*args, **kwargs): + yield (working_task, None) + + mock_client = MagicMock() + mock_client.send_message = mock_send_message + mock_client.set_task_callback = AsyncMock() + + config = PushNotificationConfig( + url=AnyHttpUrl("http://localhost:8080/a2a/callback"), + token="token", + result_store=mock_store, + ) + + test_msg = Message( + role=Role.user, + parts=[Part(root=TextPart(text="test"))], + message_id="msg-002", + ) + + new_messages: list[Message] = [] + + result = await PushNotificationHandler.execute( + client=mock_client, + message=test_msg, + new_messages=new_messages, + agent_card=mock_agent_card, + config=config, + result_store=mock_store, + polling_timeout=5.0, + polling_interval=0.5, + ) + + assert result["status"] == TaskState.failed + assert "timeout" in result.get("error", "").lower() + + @pytest.mark.asyncio + async def test_push_handler_requires_config( + self, + mock_agent_card: AgentCard, + ) -> None: + """Test that push handler fails gracefully without config.""" + from unittest.mock import MagicMock + + from crewai.a2a.updates.push_notifications.handler import PushNotificationHandler + + mock_client = MagicMock() + + test_msg = Message( + role=Role.user, + parts=[Part(root=TextPart(text="test"))], + message_id="msg-003", + ) + + new_messages: list[Message] = [] + + result = await PushNotificationHandler.execute( + client=mock_client, + message=test_msg, + new_messages=new_messages, + agent_card=mock_agent_card, + ) + + assert result["status"] == TaskState.failed + assert "config" in result.get("error", "").lower() diff --git a/lib/crewai/tests/cassettes/a2a/TestA2AAgentCardFetching.test_fetch_agent_card.yaml b/lib/crewai/tests/cassettes/a2a/TestA2AAgentCardFetching.test_fetch_agent_card.yaml new file mode 100644 index 0000000000..d60788a55d --- /dev/null +++ b/lib/crewai/tests/cassettes/a2a/TestA2AAgentCardFetching.test_fetch_agent_card.yaml @@ -0,0 +1,44 @@ +interactions: +- request: + body: '' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*' + accept-encoding: + - ACCEPT-ENCODING-XXX + connection: + - keep-alive + host: + - localhost:9999 + method: GET + uri: http://localhost:9999/.well-known/agent-card.json + response: + body: + string: '{"capabilities":{"streaming":true},"defaultInputModes":["text"],"defaultOutputModes":["text"],"description":"An + AI assistant powered by OpenAI GPT with calculator and time tools. Ask questions, + perform calculations, or get the current time in any timezone.","name":"GPT + Assistant","preferredTransport":"JSONRPC","protocolVersion":"0.3.0","skills":[{"description":"Have + a general conversation with the AI assistant. Ask questions, get explanations, + or just chat.","examples":["Hello, how are you?","Explain quantum computing + in simple terms","What can you help me with?"],"id":"conversation","name":"General + Conversation","tags":["chat","conversation","general"]},{"description":"Perform + mathematical calculations including arithmetic, exponents, and more.","examples":["What + is 25 * 17?","Calculate 2^10","What''s (100 + 50) / 3?"],"id":"calculator","name":"Calculator","tags":["math","calculator","arithmetic"]},{"description":"Get + the current date and time in any timezone.","examples":["What time is it?","What''s + the current time in Tokyo?","What''s today''s date in New York?"],"id":"time","name":"Current + Time","tags":["time","date","timezone"]}],"url":"http://localhost:9999/","version":"1.0.0"}' + headers: + content-length: + - '1198' + content-type: + - application/json + date: + - Tue, 06 Jan 2026 14:17:00 GMT + server: + - uvicorn + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/a2a/TestA2APollingIntegration.test_polling_completes_task.yaml b/lib/crewai/tests/cassettes/a2a/TestA2APollingIntegration.test_polling_completes_task.yaml new file mode 100644 index 0000000000..3832dc7da7 --- /dev/null +++ b/lib/crewai/tests/cassettes/a2a/TestA2APollingIntegration.test_polling_completes_task.yaml @@ -0,0 +1,126 @@ +interactions: +- request: + body: '' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*' + accept-encoding: + - ACCEPT-ENCODING-XXX + connection: + - keep-alive + host: + - localhost:9999 + method: GET + uri: http://localhost:9999/.well-known/agent-card.json + response: + body: + string: '{"capabilities":{"streaming":true},"defaultInputModes":["text"],"defaultOutputModes":["text"],"description":"An + AI assistant powered by OpenAI GPT with calculator and time tools. Ask questions, + perform calculations, or get the current time in any timezone.","name":"GPT + Assistant","preferredTransport":"JSONRPC","protocolVersion":"0.3.0","skills":[{"description":"Have + a general conversation with the AI assistant. Ask questions, get explanations, + or just chat.","examples":["Hello, how are you?","Explain quantum computing + in simple terms","What can you help me with?"],"id":"conversation","name":"General + Conversation","tags":["chat","conversation","general"]},{"description":"Perform + mathematical calculations including arithmetic, exponents, and more.","examples":["What + is 25 * 17?","Calculate 2^10","What''s (100 + 50) / 3?"],"id":"calculator","name":"Calculator","tags":["math","calculator","arithmetic"]},{"description":"Get + the current date and time in any timezone.","examples":["What time is it?","What''s + the current time in Tokyo?","What''s today''s date in New York?"],"id":"time","name":"Current + Time","tags":["time","date","timezone"]}],"url":"http://localhost:9999/","version":"1.0.0"}' + headers: + content-length: + - '1198' + content-type: + - application/json + date: + - Tue, 06 Jan 2026 14:16:58 GMT + server: + - uvicorn + status: + code: 200 + message: OK +- request: + body: '{"id":"e5ac2160-ae9b-4bf9-aad7-14bf0d53d6d9","jsonrpc":"2.0","method":"message/stream","params":{"configuration":{"acceptedOutputModes":[],"blocking":true},"message":{"kind":"message","messageId":"e1e63c75-3ea0-49fb-b512-5128a2476416","parts":[{"kind":"text","text":"What + is 2 + 2?"}],"role":"user"}}}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*, text/event-stream' + accept-encoding: + - ACCEPT-ENCODING-XXX + cache-control: + - no-store + connection: + - keep-alive + content-length: + - '301' + content-type: + - application/json + host: + - localhost:9999 + method: POST + uri: http://localhost:9999/ + response: + body: + string: "data: {\"id\":\"e5ac2160-ae9b-4bf9-aad7-14bf0d53d6d9\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"b9e14c1b-734d-4d1e-864a-e6dda5231d71\",\"final\":false,\"kind\":\"status-update\",\"status\":{\"state\":\"submitted\"},\"taskId\":\"0dd4d3af-f35d-409d-9462-01218e5641f9\"}}\r\n\r\ndata: + {\"id\":\"e5ac2160-ae9b-4bf9-aad7-14bf0d53d6d9\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"b9e14c1b-734d-4d1e-864a-e6dda5231d71\",\"final\":false,\"kind\":\"status-update\",\"status\":{\"state\":\"working\"},\"taskId\":\"0dd4d3af-f35d-409d-9462-01218e5641f9\"}}\r\n\r\ndata: + {\"id\":\"e5ac2160-ae9b-4bf9-aad7-14bf0d53d6d9\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"b9e14c1b-734d-4d1e-864a-e6dda5231d71\",\"final\":true,\"kind\":\"status-update\",\"status\":{\"message\":{\"kind\":\"message\",\"messageId\":\"54bb7ff3-f2c0-4eb3-b427-bf1c8cf90832\",\"parts\":[{\"kind\":\"text\",\"text\":\"\\n[Tool: + calculator] 2 + 2 = 4\\n2 + 2 equals 4.\"}],\"role\":\"agent\"},\"state\":\"completed\"},\"taskId\":\"0dd4d3af-f35d-409d-9462-01218e5641f9\"}}\r\n\r\n" + headers: + Transfer-Encoding: + - chunked + cache-control: + - no-store + connection: + - keep-alive + content-type: + - text/event-stream; charset=utf-8 + date: + - Tue, 06 Jan 2026 14:16:58 GMT + server: + - uvicorn + x-accel-buffering: + - 'no' + status: + code: 200 + message: OK +- request: + body: '{"id":"cb1e4af3-d2d0-4848-96b8-7082ee6171d1","jsonrpc":"2.0","method":"tasks/get","params":{"historyLength":100,"id":"0dd4d3af-f35d-409d-9462-01218e5641f9"}}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*' + accept-encoding: + - ACCEPT-ENCODING-XXX + connection: + - keep-alive + content-length: + - '157' + content-type: + - application/json + host: + - localhost:9999 + method: POST + uri: http://localhost:9999/ + response: + body: + string: '{"id":"cb1e4af3-d2d0-4848-96b8-7082ee6171d1","jsonrpc":"2.0","result":{"contextId":"b9e14c1b-734d-4d1e-864a-e6dda5231d71","history":[{"contextId":"b9e14c1b-734d-4d1e-864a-e6dda5231d71","kind":"message","messageId":"e1e63c75-3ea0-49fb-b512-5128a2476416","parts":[{"kind":"text","text":"What + is 2 + 2?"}],"role":"user","taskId":"0dd4d3af-f35d-409d-9462-01218e5641f9"}],"id":"0dd4d3af-f35d-409d-9462-01218e5641f9","kind":"task","status":{"message":{"kind":"message","messageId":"54bb7ff3-f2c0-4eb3-b427-bf1c8cf90832","parts":[{"kind":"text","text":"\n[Tool: + calculator] 2 + 2 = 4\n2 + 2 equals 4."}],"role":"agent"},"state":"completed"}}}' + headers: + content-length: + - '635' + content-type: + - application/json + date: + - Tue, 06 Jan 2026 14:17:00 GMT + server: + - uvicorn + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/a2a/TestA2AStreamingIntegration.test_streaming_completes_task.yaml b/lib/crewai/tests/cassettes/a2a/TestA2AStreamingIntegration.test_streaming_completes_task.yaml new file mode 100644 index 0000000000..e98e61c2b1 --- /dev/null +++ b/lib/crewai/tests/cassettes/a2a/TestA2AStreamingIntegration.test_streaming_completes_task.yaml @@ -0,0 +1,90 @@ +interactions: +- request: + body: '' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*' + accept-encoding: + - ACCEPT-ENCODING-XXX + connection: + - keep-alive + host: + - localhost:9999 + method: GET + uri: http://localhost:9999/.well-known/agent-card.json + response: + body: + string: '{"capabilities":{"streaming":true},"defaultInputModes":["text"],"defaultOutputModes":["text"],"description":"An + AI assistant powered by OpenAI GPT with calculator and time tools. Ask questions, + perform calculations, or get the current time in any timezone.","name":"GPT + Assistant","preferredTransport":"JSONRPC","protocolVersion":"0.3.0","skills":[{"description":"Have + a general conversation with the AI assistant. Ask questions, get explanations, + or just chat.","examples":["Hello, how are you?","Explain quantum computing + in simple terms","What can you help me with?"],"id":"conversation","name":"General + Conversation","tags":["chat","conversation","general"]},{"description":"Perform + mathematical calculations including arithmetic, exponents, and more.","examples":["What + is 25 * 17?","Calculate 2^10","What''s (100 + 50) / 3?"],"id":"calculator","name":"Calculator","tags":["math","calculator","arithmetic"]},{"description":"Get + the current date and time in any timezone.","examples":["What time is it?","What''s + the current time in Tokyo?","What''s today''s date in New York?"],"id":"time","name":"Current + Time","tags":["time","date","timezone"]}],"url":"http://localhost:9999/","version":"1.0.0"}' + headers: + content-length: + - '1198' + content-type: + - application/json + date: + - Tue, 06 Jan 2026 14:17:02 GMT + server: + - uvicorn + status: + code: 200 + message: OK +- request: + body: '{"id":"8cf25b61-8884-4246-adce-fccb32e176ab","jsonrpc":"2.0","method":"message/stream","params":{"configuration":{"acceptedOutputModes":[],"blocking":true},"message":{"kind":"message","messageId":"c145297f-7331-4835-adcc-66b51de92a2b","parts":[{"kind":"text","text":"What + is 2 + 2?"}],"role":"user"}}}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*, text/event-stream' + accept-encoding: + - ACCEPT-ENCODING-XXX + cache-control: + - no-store + connection: + - keep-alive + content-length: + - '301' + content-type: + - application/json + host: + - localhost:9999 + method: POST + uri: http://localhost:9999/ + response: + body: + string: "data: {\"id\":\"8cf25b61-8884-4246-adce-fccb32e176ab\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"30601267-ab3b-48ef-afc8-916c37a18651\",\"final\":false,\"kind\":\"status-update\",\"status\":{\"state\":\"submitted\"},\"taskId\":\"3083d3da-4739-4f4f-a4e8-7c048ea819c1\"}}\r\n\r\ndata: + {\"id\":\"8cf25b61-8884-4246-adce-fccb32e176ab\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"30601267-ab3b-48ef-afc8-916c37a18651\",\"final\":false,\"kind\":\"status-update\",\"status\":{\"state\":\"working\"},\"taskId\":\"3083d3da-4739-4f4f-a4e8-7c048ea819c1\"}}\r\n\r\ndata: + {\"id\":\"8cf25b61-8884-4246-adce-fccb32e176ab\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"30601267-ab3b-48ef-afc8-916c37a18651\",\"final\":true,\"kind\":\"status-update\",\"status\":{\"message\":{\"kind\":\"message\",\"messageId\":\"25f81e3c-b7e8-48b5-a98a-4066f3637a13\",\"parts\":[{\"kind\":\"text\",\"text\":\"\\n[Tool: + calculator] 2 + 2 = 4\\n2 + 2 equals 4.\"}],\"role\":\"agent\"},\"state\":\"completed\"},\"taskId\":\"3083d3da-4739-4f4f-a4e8-7c048ea819c1\"}}\r\n\r\n" + headers: + Transfer-Encoding: + - chunked + cache-control: + - no-store + connection: + - keep-alive + content-type: + - text/event-stream; charset=utf-8 + date: + - Tue, 06 Jan 2026 14:17:02 GMT + server: + - uvicorn + x-accel-buffering: + - 'no' + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/a2a/TestA2ATaskOperations.test_send_message_and_get_response.yaml b/lib/crewai/tests/cassettes/a2a/TestA2ATaskOperations.test_send_message_and_get_response.yaml new file mode 100644 index 0000000000..e3623e8da9 --- /dev/null +++ b/lib/crewai/tests/cassettes/a2a/TestA2ATaskOperations.test_send_message_and_get_response.yaml @@ -0,0 +1,90 @@ +interactions: +- request: + body: '' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*' + accept-encoding: + - ACCEPT-ENCODING-XXX + connection: + - keep-alive + host: + - localhost:9999 + method: GET + uri: http://localhost:9999/.well-known/agent-card.json + response: + body: + string: '{"capabilities":{"streaming":true},"defaultInputModes":["text"],"defaultOutputModes":["text"],"description":"An + AI assistant powered by OpenAI GPT with calculator and time tools. Ask questions, + perform calculations, or get the current time in any timezone.","name":"GPT + Assistant","preferredTransport":"JSONRPC","protocolVersion":"0.3.0","skills":[{"description":"Have + a general conversation with the AI assistant. Ask questions, get explanations, + or just chat.","examples":["Hello, how are you?","Explain quantum computing + in simple terms","What can you help me with?"],"id":"conversation","name":"General + Conversation","tags":["chat","conversation","general"]},{"description":"Perform + mathematical calculations including arithmetic, exponents, and more.","examples":["What + is 25 * 17?","Calculate 2^10","What''s (100 + 50) / 3?"],"id":"calculator","name":"Calculator","tags":["math","calculator","arithmetic"]},{"description":"Get + the current date and time in any timezone.","examples":["What time is it?","What''s + the current time in Tokyo?","What''s today''s date in New York?"],"id":"time","name":"Current + Time","tags":["time","date","timezone"]}],"url":"http://localhost:9999/","version":"1.0.0"}' + headers: + content-length: + - '1198' + content-type: + - application/json + date: + - Tue, 06 Jan 2026 14:17:00 GMT + server: + - uvicorn + status: + code: 200 + message: OK +- request: + body: '{"id":"3a17c6bf-8db6-45a6-8535-34c45c0c4936","jsonrpc":"2.0","method":"message/stream","params":{"configuration":{"acceptedOutputModes":[],"blocking":true},"message":{"kind":"message","messageId":"712558a3-6d92-4591-be8a-9dd8566dde82","parts":[{"kind":"text","text":"What + is 2 + 2?"}],"role":"user"}}}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*, text/event-stream' + accept-encoding: + - ACCEPT-ENCODING-XXX + cache-control: + - no-store + connection: + - keep-alive + content-length: + - '301' + content-type: + - application/json + host: + - localhost:9999 + method: POST + uri: http://localhost:9999/ + response: + body: + string: "data: {\"id\":\"3a17c6bf-8db6-45a6-8535-34c45c0c4936\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"ca2fbbc9-761e-45d9-a929-0c68b1f8acbf\",\"final\":false,\"kind\":\"status-update\",\"status\":{\"state\":\"submitted\"},\"taskId\":\"c6e88db0-36e9-4269-8b9a-ecb6dfdcf6a1\"}}\r\n\r\ndata: + {\"id\":\"3a17c6bf-8db6-45a6-8535-34c45c0c4936\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"ca2fbbc9-761e-45d9-a929-0c68b1f8acbf\",\"final\":false,\"kind\":\"status-update\",\"status\":{\"state\":\"working\"},\"taskId\":\"c6e88db0-36e9-4269-8b9a-ecb6dfdcf6a1\"}}\r\n\r\ndata: + {\"id\":\"3a17c6bf-8db6-45a6-8535-34c45c0c4936\",\"jsonrpc\":\"2.0\",\"result\":{\"contextId\":\"ca2fbbc9-761e-45d9-a929-0c68b1f8acbf\",\"final\":true,\"kind\":\"status-update\",\"status\":{\"message\":{\"kind\":\"message\",\"messageId\":\"916324aa-fd25-4849-bceb-c4644e2fcbb0\",\"parts\":[{\"kind\":\"text\",\"text\":\"\\n[Tool: + calculator] 2 + 2 = 4\\n2 + 2 equals 4.\"}],\"role\":\"agent\"},\"state\":\"completed\"},\"taskId\":\"c6e88db0-36e9-4269-8b9a-ecb6dfdcf6a1\"}}\r\n\r\n" + headers: + Transfer-Encoding: + - chunked + cache-control: + - no-store + connection: + - keep-alive + content-type: + - text/event-stream; charset=utf-8 + date: + - Tue, 06 Jan 2026 14:17:00 GMT + server: + - uvicorn + x-accel-buffering: + - 'no' + status: + code: 200 + message: OK +version: 1 From 5629a707ae26c87ee5e51a86fcc3be3cfd07b225 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 17:20:33 -0500 Subject: [PATCH 17/34] fix: pass push notification config in initial request --- lib/crewai/src/crewai/a2a/templates.py | 11 ++++++ .../a2a/updates/push_notifications/handler.py | 35 +++---------------- lib/crewai/src/crewai/a2a/utils.py | 26 ++++++++++++-- lib/crewai/src/crewai/a2a/wrapper.py | 13 +++++-- 4 files changed, 51 insertions(+), 34 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/templates.py b/lib/crewai/src/crewai/a2a/templates.py index 90b52fe70a..83bce22e5c 100644 --- a/lib/crewai/src/crewai/a2a/templates.py +++ b/lib/crewai/src/crewai/a2a/templates.py @@ -27,3 +27,14 @@ " $unavailable_agents" "\n\n" ) +REMOTE_AGENT_COMPLETED_NOTICE: Final[str] = """ + +STATUS: COMPLETED +The remote agent has finished processing your request. Their response is in the conversation history above. +You MUST now: +1. Extract the answer from the conversation history +2. Set is_a2a=false +3. Return the answer as your final message +DO NOT send another request - the task is already done. + +""" diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py index 1283f1e8e6..189e8aee8d 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py @@ -9,8 +9,6 @@ from a2a.types import ( AgentCard, Message, - PushNotificationConfig as A2APushNotificationConfig, - TaskPushNotificationConfig, TaskState, ) from typing_extensions import Unpack @@ -34,29 +32,10 @@ if TYPE_CHECKING: from a2a.types import Task as A2ATask - from crewai.a2a.updates.push_notifications.config import PushNotificationConfig - logger = logging.getLogger(__name__) -def _build_a2a_push_config(config: PushNotificationConfig) -> A2APushNotificationConfig: - """Convert our config to A2A SDK's PushNotificationConfig. - - Args: - config: Our PushNotificationConfig. - - Returns: - A2A SDK PushNotificationConfig. - """ - return A2APushNotificationConfig( - url=str(config.url), - id=config.id, - token=config.token, - authentication=config.authentication, - ) - - async def _wait_for_push_result( task_id: str, result_store: PushNotificationResultStore, @@ -143,6 +122,10 @@ async def execute( history=new_messages, ) + # Note: Push notification config is now included in the initial send_message + # request via ClientConfig.push_notification_configs, so no separate + # set_task_callback call is needed. This avoids race conditions where + # the task completes before the callback is registered. result_or_task_id = await send_message_and_get_task_id( event_stream=client.send_message(message), new_messages=new_messages, @@ -157,14 +140,6 @@ async def execute( task_id = result_or_task_id - a2a_push_config = _build_a2a_push_config(config) - await client.set_task_callback( - TaskPushNotificationConfig( - task_id=task_id, - push_notification_config=a2a_push_config, - ) - ) - crewai_event_bus.emit( agent_branch, A2APushNotificationRegisteredEvent( @@ -174,7 +149,7 @@ async def execute( ) logger.debug( - "Registered push notification callback for task %s at %s", + "Push notification callback for task %s configured at %s (via initial request)", task_id, config.url, ) diff --git a/lib/crewai/src/crewai/a2a/utils.py b/lib/crewai/src/crewai/a2a/utils.py index 1b0ac38081..42f5f44e06 100644 --- a/lib/crewai/src/crewai/a2a/utils.py +++ b/lib/crewai/src/crewai/a2a/utils.py @@ -15,6 +15,7 @@ AgentCard, Message, Part, + PushNotificationConfig as A2APushNotificationConfig, Role, TextPart, TransportProtocol, @@ -511,14 +512,21 @@ async def _execute_a2a_delegation_async( } ) + push_config_for_client = ( + updates if isinstance(updates, PushNotificationConfig) else None + ) + + use_streaming = not use_polling and push_config_for_client is None + async with _create_a2a_client( agent_card=agent_card, transport_protocol=transport_protocol, timeout=timeout, headers=headers, - streaming=not use_polling, + streaming=use_streaming, auth=auth, use_polling=use_polling, + push_notification_config=push_config_for_client, ) as client: return await handler.execute( client=client, @@ -538,6 +546,7 @@ async def _create_a2a_client( streaming: bool, auth: AuthScheme | None = None, use_polling: bool = False, + push_notification_config: PushNotificationConfig | None = None, ) -> AsyncIterator[Client]: """Create and configure an A2A client. @@ -549,6 +558,7 @@ async def _create_a2a_client( streaming: Enable streaming responses auth: Optional AuthScheme for client configuration use_polling: Enable polling mode + push_notification_config: Optional push notification config to include in requests Yields: Configured A2A client instance @@ -561,12 +571,24 @@ async def _create_a2a_client( if auth and isinstance(auth, (HTTPDigestAuth, APIKeyAuth)): configure_auth_client(auth, httpx_client) + push_configs: list[A2APushNotificationConfig] = [] + if push_notification_config is not None: + push_configs.append( + A2APushNotificationConfig( + url=str(push_notification_config.url), + id=push_notification_config.id, + token=push_notification_config.token, + authentication=push_notification_config.authentication, + ) + ) + config = ClientConfig( httpx_client=httpx_client, supported_transports=[str(transport_protocol.value)], streaming=streaming and not use_polling, polling=use_polling, accepted_output_modes=["application/json"], + push_notification_configs=push_configs, ) factory = ClientFactory(config) @@ -605,7 +627,7 @@ def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel]: is_a2a=( bool, Field( - description="Set to true to continue the conversation by sending this message to the A2A agent and awaiting their response. Set to false ONLY when you are completely done and providing your final answer (not when asking questions)." + description="Set to false when the remote agent has answered your question - extract their answer and return it as your final message. Set to true ONLY if you need to ask a NEW, DIFFERENT question. NEVER repeat the same request - if the conversation history shows the agent already answered, set is_a2a=false immediately." ), ), __base__=BaseModel, diff --git a/lib/crewai/src/crewai/a2a/wrapper.py b/lib/crewai/src/crewai/a2a/wrapper.py index 6b9ae022a4..63c4459210 100644 --- a/lib/crewai/src/crewai/a2a/wrapper.py +++ b/lib/crewai/src/crewai/a2a/wrapper.py @@ -21,6 +21,7 @@ AVAILABLE_AGENTS_TEMPLATE, CONVERSATION_TURN_INFO_TEMPLATE, PREVIOUS_A2A_CONVERSATION_TEMPLATE, + REMOTE_AGENT_COMPLETED_NOTICE, UNAVAILABLE_AGENTS_NOTICE_TEMPLATE, ) from crewai.a2a.types import AgentResponseProtocol @@ -256,6 +257,7 @@ def _augment_prompt_with_a2a( max_turns: int | None = None, failed_agents: dict[str, str] | None = None, extension_registry: ExtensionRegistry | None = None, + remote_task_completed: bool = False, ) -> tuple[str, bool]: """Add A2A delegation instructions to prompt. @@ -328,12 +330,15 @@ def _augment_prompt_with_a2a( warning=warning, ) + completion_notice = "" + if remote_task_completed and conversation_history: + completion_notice = REMOTE_AGENT_COMPLETED_NOTICE + augmented_prompt = f"""{task_description} IMPORTANT: You have the ability to delegate this task to remote A2A agents. {agents_text} -{history_text}{turn_info} - +{history_text}{turn_info}{completion_notice} """ @@ -383,6 +388,7 @@ def _handle_agent_response_and_continue( context: str | None, tools: list[BaseTool] | None, agent_response_model: type[BaseModel], + remote_task_completed: bool = False, ) -> tuple[str | None, str | None]: """Handle A2A result and get CrewAI agent's response. @@ -418,6 +424,7 @@ def _handle_agent_response_and_continue( turn_num=turn_num, max_turns=max_turns, agent_cards=agent_cards_dict, + remote_task_completed=remote_task_completed, ) original_response_model = task.response_model @@ -625,6 +632,7 @@ def _delegate_to_a2a( context=context, tools=tools, agent_response_model=agent_response_model, + remote_task_completed=(a2a_result["status"] == TaskState.completed), ) if final_result is not None: @@ -652,6 +660,7 @@ def _delegate_to_a2a( context=context, tools=tools, agent_response_model=agent_response_model, + remote_task_completed=False, ) if final_result is not None: From fa19a57656f914e84d0e5216c8abe463271c0173 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 17:37:06 -0500 Subject: [PATCH 18/34] chore: add a2a async updates docs --- docs/en/learn/a2a-agent-delegation.mdx | 72 ++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/en/learn/a2a-agent-delegation.mdx b/docs/en/learn/a2a-agent-delegation.mdx index ec2832751c..43f88abd28 100644 --- a/docs/en/learn/a2a-agent-delegation.mdx +++ b/docs/en/learn/a2a-agent-delegation.mdx @@ -87,6 +87,10 @@ The `A2AConfig` class accepts the following parameters: When `True`, returns the A2A agent's result directly when it signals completion. When `False`, allows the server agent to review the result and potentially continue the conversation. + + Update mechanism for receiving task status. Options: `StreamingConfig`, `PollingConfig`, or `PushNotificationConfig`. + + ## Authentication For A2A agents that require authentication, use one of the provided auth schemes: @@ -253,6 +257,74 @@ When `fail_fast=False`: - If all agents fail, the LLM receives a notice about unavailable agents and handles the task directly - Connection errors are captured and included in the context for better decision-making +## Update Mechanisms + +Control how your agent receives task status updates from remote A2A agents: + + + + ```python Code +from crewai.a2a import A2AConfig +from crewai.a2a.updates import StreamingConfig + +agent = Agent( + role="Research Coordinator", + goal="Coordinate research tasks", + backstory="Expert at delegation", + llm="gpt-4o", + a2a=A2AConfig( + endpoint="https://research.example.com/.well-known/agent-card.json", + updates=StreamingConfig() + ) +) + ``` + + + + ```python Code +from crewai.a2a import A2AConfig +from crewai.a2a.updates import PollingConfig + +agent = Agent( + role="Research Coordinator", + goal="Coordinate research tasks", + backstory="Expert at delegation", + llm="gpt-4o", + a2a=A2AConfig( + endpoint="https://research.example.com/.well-known/agent-card.json", + updates=PollingConfig( + interval=2.0, + timeout=300.0, + max_polls=100 + ) + ) +) + ``` + + + + ```python Code +from crewai.a2a import A2AConfig +from crewai.a2a.updates import PushNotificationConfig + +agent = Agent( + role="Research Coordinator", + goal="Coordinate research tasks", + backstory="Expert at delegation", + llm="gpt-4o", + a2a=A2AConfig( + endpoint="https://research.example.com/.well-known/agent-card.json", + updates=PushNotificationConfig( + url="https://your-server.com/webhook", + token="your-validation-token", + timeout=300.0 + ) + ) +) + ``` + + + ## Best Practices From 57d91439a7d0b86f43d3231841dddac2ee334674 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 17:44:35 -0500 Subject: [PATCH 19/34] chore: update test assumption, docs --- docs/en/learn/a2a-agent-delegation.mdx | 2 +- lib/crewai/tests/a2a/test_a2a_integration.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/en/learn/a2a-agent-delegation.mdx b/docs/en/learn/a2a-agent-delegation.mdx index 43f88abd28..e4c9f8228a 100644 --- a/docs/en/learn/a2a-agent-delegation.mdx +++ b/docs/en/learn/a2a-agent-delegation.mdx @@ -315,7 +315,7 @@ agent = Agent( a2a=A2AConfig( endpoint="https://research.example.com/.well-known/agent-card.json", updates=PushNotificationConfig( - url="https://your-server.com/webhook", + url={base_url}/a2a/callback", token="your-validation-token", timeout=300.0 ) diff --git a/lib/crewai/tests/a2a/test_a2a_integration.py b/lib/crewai/tests/a2a/test_a2a_integration.py index 6aa8906892..f46af4789c 100644 --- a/lib/crewai/tests/a2a/test_a2a_integration.py +++ b/lib/crewai/tests/a2a/test_a2a_integration.py @@ -172,12 +172,12 @@ def mock_task(self) -> "Task": ) @pytest.mark.asyncio - async def test_push_handler_registers_callback_and_waits( + async def test_push_handler_waits_for_result( self, mock_agent_card: AgentCard, mock_task, ) -> None: - """Test that push handler registers callback and waits for result.""" + """Test that push handler waits for result from store.""" from unittest.mock import AsyncMock, MagicMock from a2a.types import Task, TaskStatus @@ -201,7 +201,6 @@ async def mock_send_message(*args, **kwargs): mock_client = MagicMock() mock_client.send_message = mock_send_message - mock_client.set_task_callback = AsyncMock() config = PushNotificationConfig( url=AnyHttpUrl("http://localhost:8080/a2a/callback"), @@ -228,7 +227,6 @@ async def mock_send_message(*args, **kwargs): polling_interval=1.0, ) - mock_client.set_task_callback.assert_called_once() mock_store.wait_for_result.assert_called_once_with( task_id="task-123", timeout=30.0, @@ -265,7 +263,6 @@ async def mock_send_message(*args, **kwargs): mock_client = MagicMock() mock_client.send_message = mock_send_message - mock_client.set_task_callback = AsyncMock() config = PushNotificationConfig( url=AnyHttpUrl("http://localhost:8080/a2a/callback"), From b754dc19fb4e594a23cc2088ebf28662b1bdfbb9 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 17:52:41 -0500 Subject: [PATCH 20/34] fix: ensure response models checked before parsing attempt final answer was incorrectly trying to parse response models --- .../src/crewai/agents/crew_agent_executor.py | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py index 45d4f84f3e..de19934d6c 100644 --- a/lib/crewai/src/crewai/agents/crew_agent_executor.py +++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py @@ -10,7 +10,7 @@ import logging from typing import TYPE_CHECKING, Any, Literal, cast -from pydantic import BaseModel, GetCoreSchemaHandler +from pydantic import BaseModel, GetCoreSchemaHandler, ValidationError from pydantic_core import CoreSchema, core_schema from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin @@ -244,7 +244,20 @@ def _invoke_loop(self) -> AgentFinish: response_model=self.response_model, executor_context=self, ) - formatted_answer = process_llm_response(answer, self.use_stop_words) # type: ignore[assignment] + if self.response_model is not None: + try: + self.response_model.model_validate_json(answer) + formatted_answer = AgentFinish( + thought="", + output=answer, + text=answer, + ) + except ValidationError: + formatted_answer = process_llm_response( + answer, self.use_stop_words + ) # type: ignore[assignment] + else: + formatted_answer = process_llm_response(answer, self.use_stop_words) # type: ignore[assignment] if isinstance(formatted_answer, AgentAction): # Extract agent fingerprint if available @@ -278,7 +291,7 @@ def _invoke_loop(self) -> AgentFinish: ) self._invoke_step_callback(formatted_answer) # type: ignore[arg-type] - self._append_message(formatted_answer.text) # type: ignore[union-attr,attr-defined] + self._append_message(formatted_answer.text) # type: ignore[union-attr] except OutputParserError as e: formatted_answer = handle_output_parser_exception( # type: ignore[assignment] @@ -398,7 +411,21 @@ async def _ainvoke_loop(self) -> AgentFinish: response_model=self.response_model, executor_context=self, ) - formatted_answer = process_llm_response(answer, self.use_stop_words) # type: ignore[assignment] + + if self.response_model is not None: + try: + self.response_model.model_validate_json(answer) + formatted_answer = AgentFinish( + thought="", + output=answer, + text=answer, + ) + except ValidationError: + formatted_answer = process_llm_response( + answer, self.use_stop_words + ) # type: ignore[assignment] + else: + formatted_answer = process_llm_response(answer, self.use_stop_words) # type: ignore[assignment] if isinstance(formatted_answer, AgentAction): fingerprint_context = {} @@ -431,7 +458,7 @@ async def _ainvoke_loop(self) -> AgentFinish: ) self._invoke_step_callback(formatted_answer) # type: ignore[arg-type] - self._append_message(formatted_answer.text) # type: ignore[union-attr,attr-defined] + self._append_message(formatted_answer.text) # type: ignore[union-attr] except OutputParserError as e: formatted_answer = handle_output_parser_exception( # type: ignore[assignment] From f53b8755daad9e8b87fa5e82ea490547cd00eba8 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 18:38:10 -0500 Subject: [PATCH 21/34] fix: ensure failed states are handled for push, poll --- .../src/crewai/a2a/updates/polling/handler.py | 119 +++++++++------ .../a2a/updates/push_notifications/handler.py | 135 +++++++++++------- 2 files changed, 162 insertions(+), 92 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/updates/polling/handler.py b/lib/crewai/src/crewai/a2a/updates/polling/handler.py index 753a097306..1338b2b3a9 100644 --- a/lib/crewai/src/crewai/a2a/updates/polling/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/polling/handler.py @@ -5,13 +5,18 @@ import asyncio import time from typing import TYPE_CHECKING, Any +import uuid from a2a.client import Client +from a2a.client.errors import A2AClientHTTPError from a2a.types import ( AgentCard, Message, + Part, + Role, TaskQueryParams, TaskState, + TextPart, ) from typing_extensions import Unpack @@ -28,6 +33,7 @@ from crewai.events.types.a2a_events import ( A2APollingStartedEvent, A2APollingStatusEvent, + A2AResponseReceivedEvent, ) @@ -129,53 +135,84 @@ async def execute( agent_role = kwargs.get("agent_role") history_length = kwargs.get("history_length", 100) max_polls = kwargs.get("max_polls") + context_id = kwargs.get("context_id") + task_id = kwargs.get("task_id") + + try: + result_or_task_id = await send_message_and_get_task_id( + event_stream=client.send_message(message), + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) - result_or_task_id = await send_message_and_get_task_id( - event_stream=client.send_message(message), - new_messages=new_messages, - agent_card=agent_card, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, - ) + if not isinstance(result_or_task_id, str): + return result_or_task_id - if not isinstance(result_or_task_id, str): - return result_or_task_id + task_id = result_or_task_id - task_id = result_or_task_id + crewai_event_bus.emit( + agent_branch, + A2APollingStartedEvent( + task_id=task_id, + polling_interval=polling_interval, + endpoint=endpoint, + ), + ) - crewai_event_bus.emit( - agent_branch, - A2APollingStartedEvent( + final_task = await _poll_task_until_complete( + client=client, task_id=task_id, polling_interval=polling_interval, - endpoint=endpoint, - ), - ) + polling_timeout=polling_timeout, + agent_branch=agent_branch, + history_length=history_length, + max_polls=max_polls, + ) - final_task = await _poll_task_until_complete( - client=client, - task_id=task_id, - polling_interval=polling_interval, - polling_timeout=polling_timeout, - agent_branch=agent_branch, - history_length=history_length, - max_polls=max_polls, - ) + result = process_task_state( + a2a_task=final_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result - result = process_task_state( - a2a_task=final_task, - new_messages=new_messages, - agent_card=agent_card, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, - ) - if result: - return result + return TaskStateResult( + status=TaskState.failed, + error=f"Unexpected task state: {final_task.status.state}", + history=new_messages, + ) - return TaskStateResult( - status=TaskState.failed, - error=f"Unexpected task state: {final_task.status.state}", - history=new_messages, - ) + except A2AClientHTTPError as e: + error_msg = f"HTTP Error {e.status_code}: {e!s}" + + error_message = Message( + role=Role.agent, + message_id=str(uuid.uuid4()), + parts=[Part(root=TextPart(text=error_msg))], + context_id=context_id, + task_id=task_id, + ) + new_messages.append(error_message) + + crewai_event_bus.emit( + agent_branch, + A2AResponseReceivedEvent( + response=error_msg, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="failed", + agent_role=agent_role, + ), + ) + return TaskStateResult( + status=TaskState.failed, + error=error_msg, + history=new_messages, + ) diff --git a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py index 189e8aee8d..04db239f22 100644 --- a/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/push_notifications/handler.py @@ -4,12 +4,17 @@ import logging from typing import TYPE_CHECKING, Any +import uuid from a2a.client import Client +from a2a.client.errors import A2AClientHTTPError from a2a.types import ( AgentCard, Message, + Part, + Role, TaskState, + TextPart, ) from typing_extensions import Unpack @@ -26,6 +31,7 @@ from crewai.events.types.a2a_events import ( A2APushNotificationRegisteredEvent, A2APushNotificationTimeoutEvent, + A2AResponseReceivedEvent, ) @@ -107,6 +113,8 @@ async def execute( turn_number = kwargs.get("turn_number", 0) is_multiturn = kwargs.get("is_multiturn", False) agent_role = kwargs.get("agent_role") + context_id = kwargs.get("context_id") + task_id = kwargs.get("task_id") if config is None: return TaskStateResult( @@ -122,66 +130,91 @@ async def execute( history=new_messages, ) - # Note: Push notification config is now included in the initial send_message - # request via ClientConfig.push_notification_configs, so no separate - # set_task_callback call is needed. This avoids race conditions where - # the task completes before the callback is registered. - result_or_task_id = await send_message_and_get_task_id( - event_stream=client.send_message(message), - new_messages=new_messages, - agent_card=agent_card, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, - ) + try: + result_or_task_id = await send_message_and_get_task_id( + event_stream=client.send_message(message), + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) - if not isinstance(result_or_task_id, str): - return result_or_task_id + if not isinstance(result_or_task_id, str): + return result_or_task_id - task_id = result_or_task_id + task_id = result_or_task_id - crewai_event_bus.emit( - agent_branch, - A2APushNotificationRegisteredEvent( - task_id=task_id, - callback_url=str(config.url), - ), - ) + crewai_event_bus.emit( + agent_branch, + A2APushNotificationRegisteredEvent( + task_id=task_id, + callback_url=str(config.url), + ), + ) - logger.debug( - "Push notification callback for task %s configured at %s (via initial request)", - task_id, - config.url, - ) + logger.debug( + "Push notification callback for task %s configured at %s (via initial request)", + task_id, + config.url, + ) - final_task = await _wait_for_push_result( - task_id=task_id, - result_store=result_store, - timeout=polling_timeout, - poll_interval=polling_interval, - agent_branch=agent_branch, - ) + final_task = await _wait_for_push_result( + task_id=task_id, + result_store=result_store, + timeout=polling_timeout, + poll_interval=polling_interval, + agent_branch=agent_branch, + ) + + if final_task is None: + return TaskStateResult( + status=TaskState.failed, + error=f"Push notification timeout after {polling_timeout}s", + history=new_messages, + ) + + result = process_task_state( + a2a_task=final_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result - if final_task is None: return TaskStateResult( status=TaskState.failed, - error=f"Push notification timeout after {polling_timeout}s", + error=f"Unexpected task state: {final_task.status.state}", history=new_messages, ) - result = process_task_state( - a2a_task=final_task, - new_messages=new_messages, - agent_card=agent_card, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, - ) - if result: - return result + except A2AClientHTTPError as e: + error_msg = f"HTTP Error {e.status_code}: {e!s}" - return TaskStateResult( - status=TaskState.failed, - error=f"Unexpected task state: {final_task.status.state}", - history=new_messages, - ) + error_message = Message( + role=Role.agent, + message_id=str(uuid.uuid4()), + parts=[Part(root=TextPart(text=error_msg))], + context_id=context_id, + task_id=task_id, + ) + new_messages.append(error_message) + + crewai_event_bus.emit( + agent_branch, + A2AResponseReceivedEvent( + response=error_msg, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="failed", + agent_role=agent_role, + ), + ) + return TaskStateResult( + status=TaskState.failed, + error=error_msg, + history=new_messages, + ) From c639455730e6de62e177e0d6af44e1db09d393e2 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 18:52:01 -0500 Subject: [PATCH 22/34] fix: ensure stream is closed on exit --- lib/crewai/src/crewai/a2a/task_helpers.py | 92 ++++++++++--------- .../crewai/a2a/updates/streaming/handler.py | 5 + 2 files changed, 54 insertions(+), 43 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/task_helpers.py b/lib/crewai/src/crewai/a2a/task_helpers.py index 1083f3fea1..9e24c7e32c 100644 --- a/lib/crewai/src/crewai/a2a/task_helpers.py +++ b/lib/crewai/src/crewai/a2a/task_helpers.py @@ -264,51 +264,57 @@ async def send_message_and_get_task_id( Returns: Task ID string if agent needs polling/waiting, or TaskStateResult if done. """ - async for event in event_stream: - if isinstance(event, Message): - new_messages.append(event) - result_parts = [ - part.root.text for part in event.parts if part.root.kind == "text" - ] - response_text = " ".join(result_parts) if result_parts else "" - - crewai_event_bus.emit( - None, - A2AResponseReceivedEvent( - response=response_text, - turn_number=turn_number, - is_multiturn=is_multiturn, - status="completed", - agent_role=agent_role, - ), - ) - - return TaskStateResult( - status=TaskState.completed, - result=response_text, - history=new_messages, - agent_card=agent_card, - ) - - if isinstance(event, tuple): - a2a_task, _ = event + try: + async for event in event_stream: + if isinstance(event, Message): + new_messages.append(event) + result_parts = [ + part.root.text for part in event.parts if part.root.kind == "text" + ] + response_text = " ".join(result_parts) if result_parts else "" + + crewai_event_bus.emit( + None, + A2AResponseReceivedEvent( + response=response_text, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="completed", + agent_role=agent_role, + ), + ) - if a2a_task.status.state in TERMINAL_STATES | ACTIONABLE_STATES: - result = process_task_state( - a2a_task=a2a_task, - new_messages=new_messages, + return TaskStateResult( + status=TaskState.completed, + result=response_text, + history=new_messages, agent_card=agent_card, - turn_number=turn_number, - is_multiturn=is_multiturn, - agent_role=agent_role, ) - if result: - return result - return a2a_task.id + if isinstance(event, tuple): + a2a_task, _ = event + + if a2a_task.status.state in TERMINAL_STATES | ACTIONABLE_STATES: + result = process_task_state( + a2a_task=a2a_task, + new_messages=new_messages, + agent_card=agent_card, + turn_number=turn_number, + is_multiturn=is_multiturn, + agent_role=agent_role, + ) + if result: + return result + + return a2a_task.id + + return TaskStateResult( + status=TaskState.failed, + error="No task ID received from initial message", + history=new_messages, + ) - return TaskStateResult( - status=TaskState.failed, - error="No task ID received from initial message", - history=new_messages, - ) + finally: + aclose = getattr(event_stream, "aclose", None) + if aclose: + await aclose() diff --git a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py index eb05724e7b..556374edfc 100644 --- a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py @@ -133,6 +133,11 @@ async def execute( history=new_messages, ) + finally: + aclose = getattr(event_stream, "aclose", None) + if aclose: + await aclose() + if final_result: return final_result From 8c9b1fbff784c9b6da6b6359de8f40cb1a539e69 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 19:02:29 -0500 Subject: [PATCH 23/34] fix: add error catch for polling timeout --- .../src/crewai/a2a/updates/polling/handler.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lib/crewai/src/crewai/a2a/updates/polling/handler.py b/lib/crewai/src/crewai/a2a/updates/polling/handler.py index 1338b2b3a9..e0518be9be 100644 --- a/lib/crewai/src/crewai/a2a/updates/polling/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/polling/handler.py @@ -189,6 +189,34 @@ async def execute( history=new_messages, ) + except A2APollingTimeoutError as e: + error_msg = str(e) + + error_message = Message( + role=Role.agent, + message_id=str(uuid.uuid4()), + parts=[Part(root=TextPart(text=error_msg))], + context_id=context_id, + task_id=task_id, + ) + new_messages.append(error_message) + + crewai_event_bus.emit( + agent_branch, + A2AResponseReceivedEvent( + response=error_msg, + turn_number=turn_number, + is_multiturn=is_multiturn, + status="failed", + agent_role=agent_role, + ), + ) + return TaskStateResult( + status=TaskState.failed, + error=error_msg, + history=new_messages, + ) + except A2AClientHTTPError as e: error_msg = f"HTTP Error {e.status_code}: {e!s}" From 0230cb67c4b56009b016a4cae266a6a86489e4fa Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 19:54:08 -0500 Subject: [PATCH 24/34] feat: add agent_card to conditional branch fallback --- lib/crewai/src/crewai/a2a/updates/streaming/handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py index 556374edfc..93d2c4500b 100644 --- a/lib/crewai/src/crewai/a2a/updates/streaming/handler.py +++ b/lib/crewai/src/crewai/a2a/updates/streaming/handler.py @@ -145,4 +145,5 @@ async def execute( status=TaskState.completed, result=" ".join(result_parts) if result_parts else "", history=new_messages, + agent_card=agent_card, ) From 1da060c4eba94a25741172d20dc04237c5c06498 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Tue, 6 Jan 2026 20:29:39 -0500 Subject: [PATCH 25/34] fix: ensure artifacts are not duplicated --- lib/crewai/src/crewai/a2a/task_helpers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/task_helpers.py b/lib/crewai/src/crewai/a2a/task_helpers.py index 9e24c7e32c..9a84a1ffc3 100644 --- a/lib/crewai/src/crewai/a2a/task_helpers.py +++ b/lib/crewai/src/crewai/a2a/task_helpers.py @@ -149,12 +149,14 @@ def process_task_state( Returns: Result dictionary if terminal/actionable state, None otherwise """ + should_extract = result_parts is None if result_parts is None: result_parts = [] if a2a_task.status.state == TaskState.completed: - extracted_parts = extract_task_result_parts(a2a_task) - result_parts.extend(extracted_parts) + if should_extract: + extracted_parts = extract_task_result_parts(a2a_task) + result_parts.extend(extracted_parts) if a2a_task.history: new_messages.extend(a2a_task.history) From 556f82a854e8b62c08a155f42b20715002367988 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 7 Jan 2026 01:31:00 -0500 Subject: [PATCH 26/34] feat: move handler types to types.py and add native async a2a functions --- lib/crewai/src/crewai/a2a/types.py | 23 + lib/crewai/src/crewai/a2a/utils.py | 326 ++++++----- lib/crewai/src/crewai/a2a/wrapper.py | 783 +++++++++++++++++++++------ 3 files changed, 837 insertions(+), 295 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/types.py b/lib/crewai/src/crewai/a2a/types.py index fca22d8bbc..217b59467c 100644 --- a/lib/crewai/src/crewai/a2a/types.py +++ b/lib/crewai/src/crewai/a2a/types.py @@ -4,6 +4,16 @@ from typing_extensions import NotRequired +from crewai.a2a.updates import ( + PollingConfig, + PollingHandler, + PushNotificationConfig, + PushNotificationHandler, + StreamingConfig, + StreamingHandler, + UpdateConfig, +) + @runtime_checkable class AgentResponseProtocol(Protocol): @@ -36,3 +46,16 @@ class PartsDict(TypedDict): text: str metadata: NotRequired[PartsMetadataDict] + + +PollingHandlerType = type[PollingHandler] +StreamingHandlerType = type[StreamingHandler] +PushNotificationHandlerType = type[PushNotificationHandler] + +HandlerType = PollingHandlerType | StreamingHandlerType | PushNotificationHandlerType + +HANDLER_REGISTRY: dict[type[UpdateConfig], HandlerType] = { + PollingConfig: PollingHandler, + StreamingConfig: StreamingHandler, + PushNotificationConfig: PushNotificationHandler, +} diff --git a/lib/crewai/src/crewai/a2a/utils.py b/lib/crewai/src/crewai/a2a/utils.py index 42f5f44e06..4b3ba23e9e 100644 --- a/lib/crewai/src/crewai/a2a/utils.py +++ b/lib/crewai/src/crewai/a2a/utils.py @@ -34,13 +34,15 @@ ) from crewai.a2a.config import A2AConfig from crewai.a2a.task_helpers import TaskStateResult -from crewai.a2a.types import PartsDict, PartsMetadataDict +from crewai.a2a.types import ( + HANDLER_REGISTRY, + HandlerType, + PartsDict, + PartsMetadataDict, +) from crewai.a2a.updates import ( PollingConfig, - PollingHandler, PushNotificationConfig, - PushNotificationHandler, - StreamingConfig, StreamingHandler, UpdateConfig, ) @@ -60,17 +62,6 @@ from crewai.a2a.auth.schemas import AuthScheme -HandlerType = ( - type[PollingHandler] | type[StreamingHandler] | type[PushNotificationHandler] -) - -HANDLER_REGISTRY: dict[type[UpdateConfig], HandlerType] = { - PollingConfig: PollingHandler, - StreamingConfig: StreamingHandler, - PushNotificationConfig: PushNotificationHandler, -} - - def get_handler(config: UpdateConfig | None) -> HandlerType: """Get the handler class for a given update config. @@ -92,24 +83,14 @@ def _fetch_agent_card_cached( timeout: int, _ttl_hash: int, ) -> AgentCard: - """Cached version of fetch_agent_card with auth support. - - Args: - endpoint: A2A agent endpoint URL - auth_hash: Hash of the auth object - timeout: Request timeout - _ttl_hash: Time-based hash for cache invalidation - - Returns: - Cached AgentCard - """ + """Cached sync version of fetch_agent_card.""" auth = _auth_store.get(auth_hash) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: return loop.run_until_complete( - _fetch_agent_card_async(endpoint=endpoint, auth=auth, timeout=timeout) + _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) ) finally: loop.close() @@ -159,47 +140,74 @@ def fetch_agent_card( asyncio.set_event_loop(loop) try: return loop.run_until_complete( - _fetch_agent_card_async(endpoint=endpoint, auth=auth, timeout=timeout) + afetch_agent_card(endpoint=endpoint, auth=auth, timeout=timeout) ) finally: loop.close() -@cached(ttl=300, serializer=PickleSerializer()) # type: ignore[untyped-decorator] -async def _fetch_agent_card_async_cached( +async def afetch_agent_card( endpoint: str, - auth_hash: int, - timeout: int, + auth: AuthScheme | None = None, + timeout: int = 30, + use_cache: bool = True, ) -> AgentCard: - """Cached async implementation of AgentCard fetching. + """Fetch AgentCard from an A2A endpoint asynchronously. + + Native async implementation. Use this when running in an async context. Args: - endpoint: A2A agent endpoint URL - auth_hash: Hash of the auth object - timeout: Request timeout in seconds + endpoint: A2A agent endpoint URL (AgentCard URL). + auth: Optional AuthScheme for authentication. + timeout: Request timeout in seconds. + use_cache: Whether to use caching (default True). Returns: - Cached AgentCard object + AgentCard object with agent capabilities and skills. + + Raises: + httpx.HTTPStatusError: If the request fails. + A2AClientHTTPError: If authentication fails. """ - auth = _auth_store.get(auth_hash) - return await _fetch_agent_card_async(endpoint=endpoint, auth=auth, timeout=timeout) + if use_cache: + if auth: + auth_data = auth.model_dump_json( + exclude={ + "_access_token", + "_token_expires_at", + "_refresh_token", + "_authorization_callback", + } + ) + auth_hash = hash((type(auth).__name__, auth_data)) + else: + auth_hash = 0 + _auth_store[auth_hash] = auth + agent_card: AgentCard = await _afetch_agent_card_cached( + endpoint, auth_hash, timeout + ) + return agent_card + return await _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) -async def _fetch_agent_card_async( + +@cached(ttl=300, serializer=PickleSerializer()) # type: ignore[untyped-decorator] +async def _afetch_agent_card_cached( endpoint: str, - auth: AuthScheme | None, + auth_hash: int, timeout: int, ) -> AgentCard: - """Async implementation of AgentCard fetching. + """Cached async implementation of AgentCard fetching.""" + auth = _auth_store.get(auth_hash) + return await _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) - Args: - endpoint: A2A agent endpoint URL - auth: Optional AuthScheme for authentication - timeout: Request timeout in seconds - Returns: - AgentCard object - """ +async def _afetch_agent_card_impl( + endpoint: str, + auth: AuthScheme | None, + timeout: int, +) -> AgentCard: + """Internal async implementation of AgentCard fetching.""" if "/.well-known/agent-card.json" in endpoint: base_url = endpoint.replace("/.well-known/agent-card.json", "") agent_card_path = "/.well-known/agent-card.json" @@ -268,51 +276,38 @@ def execute_a2a_delegation( turn_number: int | None = None, updates: UpdateConfig | None = None, ) -> TaskStateResult: - """Execute a task delegation to a remote A2A agent with multi-turn support. + """Execute a task delegation to a remote A2A agent synchronously. + + This is the sync wrapper around aexecute_a2a_delegation. For async contexts, + use aexecute_a2a_delegation directly. Args: - endpoint: A2A agent endpoint URL - auth: Optional AuthScheme for authentication - timeout: Request timeout in seconds - task_description: The task to delegate - context: Optional context information - context_id: Context ID for correlating messages/tasks - task_id: Specific task identifier - reference_task_ids: List of related task IDs - metadata: Additional metadata - extensions: Protocol extensions for custom fields - conversation_history: Previous Message objects from conversation - agent_id: Agent identifier for logging - agent_role: Role of the CrewAI agent delegating the task - agent_branch: Optional agent tree branch for logging - response_model: Optional Pydantic model for structured outputs - turn_number: Optional turn number for multi-turn conversations - updates: Update mechanism config from A2AConfig.updates + endpoint: A2A agent endpoint URL. + auth: Optional AuthScheme for authentication. + timeout: Request timeout in seconds. + task_description: The task to delegate. + context: Optional context information. + context_id: Context ID for correlating messages/tasks. + task_id: Specific task identifier. + reference_task_ids: List of related task IDs. + metadata: Additional metadata. + extensions: Protocol extensions for custom fields. + conversation_history: Previous Message objects from conversation. + agent_id: Agent identifier for logging. + agent_role: Role of the CrewAI agent delegating the task. + agent_branch: Optional agent tree branch for logging. + response_model: Optional Pydantic model for structured outputs. + turn_number: Optional turn number for multi-turn conversations. + updates: Update mechanism config from A2AConfig.updates. Returns: - TaskStateResult with status, result/error, history, and agent_card + TaskStateResult with status, result/error, history, and agent_card. """ - is_multiturn = bool(conversation_history and len(conversation_history) > 0) - if turn_number is None: - turn_number = ( - len([m for m in (conversation_history or []) if m.role == Role.user]) + 1 - ) - crewai_event_bus.emit( - agent_branch, - A2ADelegationStartedEvent( - endpoint=endpoint, - task_description=task_description, - agent_id=agent_id, - is_multiturn=is_multiturn, - turn_number=turn_number, - ), - ) - loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - result = loop.run_until_complete( - _execute_a2a_delegation_async( + return loop.run_until_complete( + aexecute_a2a_delegation( endpoint=endpoint, auth=auth, timeout=timeout, @@ -323,77 +318,138 @@ def execute_a2a_delegation( reference_task_ids=reference_task_ids, metadata=metadata, extensions=extensions, - conversation_history=conversation_history or [], - is_multiturn=is_multiturn, - turn_number=turn_number, - agent_branch=agent_branch, + conversation_history=conversation_history, agent_id=agent_id, agent_role=agent_role, + agent_branch=agent_branch, response_model=response_model, + turn_number=turn_number, updates=updates, ) ) - - crewai_event_bus.emit( - agent_branch, - A2ADelegationCompletedEvent( - status=result["status"], - result=result.get("result"), - error=result.get("error"), - is_multiturn=is_multiturn, - ), - ) - - return result finally: loop.close() -async def _execute_a2a_delegation_async( +async def aexecute_a2a_delegation( endpoint: str, auth: AuthScheme | None, timeout: int, task_description: str, - context: str | None, - context_id: str | None, - task_id: str | None, - reference_task_ids: list[str] | None, - metadata: dict[str, Any] | None, - extensions: dict[str, Any] | None, - conversation_history: list[Message], - is_multiturn: bool = False, - turn_number: int = 1, - agent_branch: Any | None = None, + context: str | None = None, + context_id: str | None = None, + task_id: str | None = None, + reference_task_ids: list[str] | None = None, + metadata: dict[str, Any] | None = None, + extensions: dict[str, Any] | None = None, + conversation_history: list[Message] | None = None, agent_id: str | None = None, - agent_role: str | None = None, + agent_role: Role | None = None, + agent_branch: Any | None = None, response_model: type[BaseModel] | None = None, + turn_number: int | None = None, updates: UpdateConfig | None = None, ) -> TaskStateResult: - """Async implementation of A2A delegation with multi-turn support. + """Execute a task delegation to a remote A2A agent asynchronously. + + Native async implementation with multi-turn support. Use this when running + in an async context (e.g., with Crew.akickoff() or agent.aexecute_task()). Args: - endpoint: A2A agent endpoint URL - auth: Optional AuthScheme for authentication - timeout: Request timeout in seconds - task_description: Task to delegate - context: Optional context - context_id: Context ID for correlation - task_id: Specific task identifier - reference_task_ids: Related task IDs - metadata: Additional metadata - extensions: Protocol extensions - conversation_history: Previous Message objects - is_multiturn: Whether this is a multi-turn conversation - turn_number: Current turn number - agent_branch: Agent tree branch for logging - agent_id: Agent identifier for logging - agent_role: Agent role for logging - response_model: Optional Pydantic model for structured outputs - updates: Update mechanism config + endpoint: A2A agent endpoint URL. + auth: Optional AuthScheme for authentication. + timeout: Request timeout in seconds. + task_description: The task to delegate. + context: Optional context information. + context_id: Context ID for correlating messages/tasks. + task_id: Specific task identifier. + reference_task_ids: List of related task IDs. + metadata: Additional metadata. + extensions: Protocol extensions for custom fields. + conversation_history: Previous Message objects from conversation. + agent_id: Agent identifier for logging. + agent_role: Role of the CrewAI agent delegating the task. + agent_branch: Optional agent tree branch for logging. + response_model: Optional Pydantic model for structured outputs. + turn_number: Optional turn number for multi-turn conversations. + updates: Update mechanism config from A2AConfig.updates. Returns: - TaskStateResult with status, result/error, history, and agent_card + TaskStateResult with status, result/error, history, and agent_card. """ + if conversation_history is None: + conversation_history = [] + + is_multiturn = len(conversation_history) > 0 + if turn_number is None: + turn_number = len([m for m in conversation_history if m.role == Role.user]) + 1 + + crewai_event_bus.emit( + agent_branch, + A2ADelegationStartedEvent( + endpoint=endpoint, + task_description=task_description, + agent_id=agent_id, + is_multiturn=is_multiturn, + turn_number=turn_number, + ), + ) + + result = await _aexecute_a2a_delegation_impl( + endpoint=endpoint, + auth=auth, + timeout=timeout, + task_description=task_description, + context=context, + context_id=context_id, + task_id=task_id, + reference_task_ids=reference_task_ids, + metadata=metadata, + extensions=extensions, + conversation_history=conversation_history, + is_multiturn=is_multiturn, + turn_number=turn_number, + agent_branch=agent_branch, + agent_id=agent_id, + agent_role=agent_role, + response_model=response_model, + updates=updates, + ) + + crewai_event_bus.emit( + agent_branch, + A2ADelegationCompletedEvent( + status=result["status"], + result=result.get("result"), + error=result.get("error"), + is_multiturn=is_multiturn, + ), + ) + + return result + + +async def _aexecute_a2a_delegation_impl( + endpoint: str, + auth: AuthScheme | None, + timeout: int, + task_description: str, + context: str | None, + context_id: str | None, + task_id: str | None, + reference_task_ids: list[str] | None, + metadata: dict[str, Any] | None, + extensions: dict[str, Any] | None, + conversation_history: list[Message], + is_multiturn: bool, + turn_number: int, + agent_branch: Any | None, + agent_id: str | None, + agent_role: str | None, + response_model: type[BaseModel] | None, + updates: UpdateConfig | None, +) -> TaskStateResult: + """Internal async implementation of A2A delegation.""" if auth: auth_data = auth.model_dump_json( exclude={ @@ -407,7 +463,7 @@ async def _execute_a2a_delegation_async( else: auth_hash = 0 _auth_store[auth_hash] = auth - agent_card = await _fetch_agent_card_async_cached( + agent_card = await _afetch_agent_card_cached( endpoint=endpoint, auth_hash=auth_hash, timeout=timeout ) diff --git a/lib/crewai/src/crewai/a2a/wrapper.py b/lib/crewai/src/crewai/a2a/wrapper.py index 63c4459210..fc4cdc2c63 100644 --- a/lib/crewai/src/crewai/a2a/wrapper.py +++ b/lib/crewai/src/crewai/a2a/wrapper.py @@ -5,7 +5,8 @@ from __future__ import annotations -from collections.abc import Callable +import asyncio +from collections.abc import Callable, Coroutine from concurrent.futures import ThreadPoolExecutor, as_completed from functools import wraps from types import MethodType @@ -26,6 +27,8 @@ ) from crewai.a2a.types import AgentResponseProtocol from crewai.a2a.utils import ( + aexecute_a2a_delegation, + afetch_agent_card, execute_a2a_delegation, fetch_agent_card, get_a2a_agents_and_response_model, @@ -48,15 +51,15 @@ def wrap_agent_with_a2a_instance( agent: Agent, extension_registry: ExtensionRegistry | None = None ) -> None: - """Wrap an agent instance's execute_task method with A2A support. + """Wrap an agent instance's execute_task and aexecute_task methods with A2A support. This function modifies the agent instance by wrapping its execute_task - method to add A2A delegation capabilities. Should only be called when - the agent has a2a configuration set. + and aexecute_task methods to add A2A delegation capabilities. Should only + be called when the agent has a2a configuration set. Args: - agent: The agent instance to wrap - extension_registry: Optional registry of A2A extensions for injecting tools and custom logic + agent: The agent instance to wrap. + extension_registry: Optional registry of A2A extensions. """ if extension_registry is None: extension_registry = ExtensionRegistry() @@ -64,6 +67,7 @@ def wrap_agent_with_a2a_instance( extension_registry.inject_all_tools(agent) original_execute_task = agent.execute_task.__func__ # type: ignore[attr-defined] + original_aexecute_task = agent.aexecute_task.__func__ # type: ignore[attr-defined] @wraps(original_execute_task) def execute_task_with_a2a( @@ -72,17 +76,7 @@ def execute_task_with_a2a( context: str | None = None, tools: list[BaseTool] | None = None, ) -> str: - """Execute task with A2A delegation support. - - Args: - self: The agent instance - task: The task to execute - context: Optional context for task execution - tools: Optional tools available to the agent - - Returns: - Task execution result - """ + """Execute task with A2A delegation support (sync).""" if not self.a2a: return original_execute_task(self, task, context, tools) # type: ignore[no-any-return] @@ -99,7 +93,34 @@ def execute_task_with_a2a( extension_registry=extension_registry, ) + @wraps(original_aexecute_task) + async def aexecute_task_with_a2a( + self: Agent, + task: Task, + context: str | None = None, + tools: list[BaseTool] | None = None, + ) -> str: + """Execute task with A2A delegation support (async).""" + if not self.a2a: + return await original_aexecute_task(self, task, context, tools) # type: ignore[no-any-return] + + a2a_agents, agent_response_model = get_a2a_agents_and_response_model(self.a2a) + + return await _aexecute_task_with_a2a( + self=self, + a2a_agents=a2a_agents, + original_fn=original_aexecute_task, + task=task, + agent_response_model=agent_response_model, + context=context, + tools=tools, + extension_registry=extension_registry, + ) + object.__setattr__(agent, "execute_task", MethodType(execute_task_with_a2a, agent)) + object.__setattr__( + agent, "aexecute_task", MethodType(aexecute_task_with_a2a, agent) + ) def _fetch_card_from_config( @@ -353,15 +374,7 @@ def _augment_prompt_with_a2a( def _parse_agent_response( raw_result: str | dict[str, Any], agent_response_model: type[BaseModel] ) -> BaseModel | str | dict[str, Any]: - """Parse LLM output as AgentResponse or return raw agent response. - - Args: - raw_result: Raw output from LLM - agent_response_model: The agent response model - - Returns: - Parsed AgentResponse, or raw result if parsing fails - """ + """Parse LLM output as AgentResponse or return raw agent response.""" if agent_response_model: try: if isinstance(raw_result, str): @@ -373,69 +386,66 @@ def _parse_agent_response( return raw_result -def _handle_agent_response_and_continue( - self: Agent, - a2a_result: TaskStateResult, - agent_id: str, - agent_cards: dict[str, AgentCard] | None, - a2a_agents: list[A2AConfig], - original_task_description: str, +def _handle_max_turns_exceeded( conversation_history: list[Message], - turn_num: int, max_turns: int, - task: Task, - original_fn: Callable[..., str], - context: str | None, - tools: list[BaseTool] | None, - agent_response_model: type[BaseModel], - remote_task_completed: bool = False, -) -> tuple[str | None, str | None]: - """Handle A2A result and get CrewAI agent's response. +) -> str: + """Handle the case when max turns is exceeded. - Args: - self: The agent instance - a2a_result: Result from A2A delegation - agent_id: ID of the A2A agent - agent_cards: Pre-fetched agent cards - a2a_agents: List of A2A configurations - original_task_description: Original task description - conversation_history: Conversation history - turn_num: Current turn number - max_turns: Maximum turns allowed - task: The task being executed - original_fn: Original execute_task method - context: Optional context - tools: Optional tools - agent_response_model: Response model for parsing + Shared logic for both sync and async delegation. Returns: - Tuple of (final_result, current_request) where: - - final_result is not None if conversation should end - - current_request is the next message to send if continuing - """ - agent_cards_dict = agent_cards or {} - if "agent_card" in a2a_result and agent_id not in agent_cards_dict: - agent_cards_dict[agent_id] = a2a_result["agent_card"] + Final message if found in history. - task.description, disable_structured_output = _augment_prompt_with_a2a( - a2a_agents=a2a_agents, - task_description=original_task_description, - conversation_history=conversation_history, - turn_num=turn_num, - max_turns=max_turns, - agent_cards=agent_cards_dict, - remote_task_completed=remote_task_completed, + Raises: + Exception: If no final message found and max turns exceeded. + """ + if conversation_history: + for msg in reversed(conversation_history): + if msg.role == Role.agent: + text_parts = [ + part.root.text for part in msg.parts if part.root.kind == "text" + ] + final_message = ( + " ".join(text_parts) if text_parts else "Conversation completed" + ) + crewai_event_bus.emit( + None, + A2AConversationCompletedEvent( + status="completed", + final_result=final_message, + error=None, + total_turns=max_turns, + ), + ) + return final_message + + crewai_event_bus.emit( + None, + A2AConversationCompletedEvent( + status="failed", + final_result=None, + error=f"Conversation exceeded maximum turns ({max_turns})", + total_turns=max_turns, + ), ) + raise Exception(f"A2A conversation exceeded maximum turns ({max_turns})") - original_response_model = task.response_model - if disable_structured_output: - task.response_model = None - raw_result = original_fn(self, task, context, tools) +def _process_response_result( + raw_result: str, + disable_structured_output: bool, + turn_num: int, + agent_role: str, + agent_response_model: type[BaseModel], +) -> tuple[str | None, str | None]: + """Process LLM response and determine next action. - if disable_structured_output: - task.response_model = original_response_model + Shared logic for both sync and async handlers. + Returns: + Tuple of (final_result, next_request). + """ if disable_structured_output: final_turn_number = turn_num + 1 result_text = str(raw_result) @@ -445,7 +455,7 @@ def _handle_agent_response_and_continue( message=result_text, turn_number=final_turn_number, is_multiturn=True, - agent_role=self.role, + agent_role=agent_role, ), ) crewai_event_bus.emit( @@ -474,7 +484,7 @@ def _handle_agent_response_and_continue( message=str(llm_response.message), turn_number=final_turn_number, is_multiturn=True, - agent_role=self.role, + agent_role=agent_role, ), ) crewai_event_bus.emit( @@ -492,6 +502,200 @@ def _handle_agent_response_and_continue( return str(raw_result), None +def _prepare_agent_cards_dict( + a2a_result: TaskStateResult, + agent_id: str, + agent_cards: dict[str, AgentCard] | None, +) -> dict[str, AgentCard]: + """Prepare agent cards dictionary from result and existing cards. + + Shared logic for both sync and async response handlers. + """ + agent_cards_dict = agent_cards or {} + if "agent_card" in a2a_result and agent_id not in agent_cards_dict: + agent_cards_dict[agent_id] = a2a_result["agent_card"] + return agent_cards_dict + + +def _prepare_delegation_context( + self: Agent, + agent_response: AgentResponseProtocol, + task: Task, + original_task_description: str | None, +) -> tuple[ + list[A2AConfig], + type[BaseModel], + str, + str, + A2AConfig, + str | None, + str | None, + dict[str, Any] | None, + dict[str, Any] | None, + list[str], + str, + int, +]: + """Prepare delegation context from agent response and task. + + Shared logic for both sync and async delegation. + + Returns: + Tuple containing all the context values needed for delegation. + """ + a2a_agents, agent_response_model = get_a2a_agents_and_response_model(self.a2a) + agent_ids = tuple(config.endpoint for config in a2a_agents) + current_request = str(agent_response.message) + + if hasattr(agent_response, "a2a_ids") and agent_response.a2a_ids: + agent_id = agent_response.a2a_ids[0] + else: + agent_id = agent_ids[0] if agent_ids else "" + + if agent_id and agent_id not in agent_ids: + raise ValueError( + f"Unknown A2A agent ID(s): {agent_response.a2a_ids} not in {agent_ids}" + ) + + agent_config = next(filter(lambda x: x.endpoint == agent_id, a2a_agents)) + task_config = task.config or {} + context_id = task_config.get("context_id") + task_id_config = task_config.get("task_id") + metadata = task_config.get("metadata") + extensions = task_config.get("extensions") + reference_task_ids = task_config.get("reference_task_ids", []) + + if original_task_description is None: + original_task_description = task.description + + max_turns = agent_config.max_turns + + return ( + a2a_agents, + agent_response_model, + current_request, + agent_id, + agent_config, + context_id, + task_id_config, + metadata, + extensions, + reference_task_ids, + original_task_description, + max_turns, + ) + + +def _handle_task_completion( + a2a_result: TaskStateResult, + task: Task, + task_id_config: str | None, + reference_task_ids: list[str], + agent_config: A2AConfig, + turn_num: int, +) -> tuple[str | None, str | None, list[str]]: + """Handle task completion state including reference task updates. + + Shared logic for both sync and async delegation. + + Returns: + Tuple of (result_if_trusted, updated_task_id, updated_reference_task_ids). + """ + if a2a_result["status"] == TaskState.completed: + if task_id_config is not None and task_id_config not in reference_task_ids: + reference_task_ids.append(task_id_config) + if task.config is None: + task.config = {} + task.config["reference_task_ids"] = reference_task_ids + task_id_config = None + + if agent_config.trust_remote_completion_status: + result_text = a2a_result.get("result", "") + final_turn_number = turn_num + 1 + crewai_event_bus.emit( + None, + A2AConversationCompletedEvent( + status="completed", + final_result=result_text, + error=None, + total_turns=final_turn_number, + ), + ) + return str(result_text), task_id_config, reference_task_ids + + return None, task_id_config, reference_task_ids + + +def _handle_agent_response_and_continue( + self: Agent, + a2a_result: TaskStateResult, + agent_id: str, + agent_cards: dict[str, AgentCard] | None, + a2a_agents: list[A2AConfig], + original_task_description: str, + conversation_history: list[Message], + turn_num: int, + max_turns: int, + task: Task, + original_fn: Callable[..., str], + context: str | None, + tools: list[BaseTool] | None, + agent_response_model: type[BaseModel], + remote_task_completed: bool = False, +) -> tuple[str | None, str | None]: + """Handle A2A result and get CrewAI agent's response. + + Args: + self: The agent instance + a2a_result: Result from A2A delegation + agent_id: ID of the A2A agent + agent_cards: Pre-fetched agent cards + a2a_agents: List of A2A configurations + original_task_description: Original task description + conversation_history: Conversation history + turn_num: Current turn number + max_turns: Maximum turns allowed + task: The task being executed + original_fn: Original execute_task method + context: Optional context + tools: Optional tools + agent_response_model: Response model for parsing + + Returns: + Tuple of (final_result, current_request) where: + - final_result is not None if conversation should end + - current_request is the next message to send if continuing + """ + agent_cards_dict = _prepare_agent_cards_dict(a2a_result, agent_id, agent_cards) + + task.description, disable_structured_output = _augment_prompt_with_a2a( + a2a_agents=a2a_agents, + task_description=original_task_description, + conversation_history=conversation_history, + turn_num=turn_num, + max_turns=max_turns, + agent_cards=agent_cards_dict, + remote_task_completed=remote_task_completed, + ) + + original_response_model = task.response_model + if disable_structured_output: + task.response_model = None + + raw_result = original_fn(self, task, context, tools) + + if disable_structured_output: + task.response_model = original_response_model + + return _process_response_result( + raw_result=raw_result, + disable_structured_output=disable_structured_output, + turn_num=turn_num, + agent_role=self.role, + agent_response_model=agent_response_model, + ) + + def _delegate_to_a2a( self: Agent, agent_response: AgentResponseProtocol, @@ -522,34 +726,338 @@ def _delegate_to_a2a( Raises: ImportError: If a2a-sdk is not installed """ - a2a_agents, agent_response_model = get_a2a_agents_and_response_model(self.a2a) - agent_ids = tuple(config.endpoint for config in a2a_agents) - current_request = str(agent_response.message) + ( + a2a_agents, + agent_response_model, + current_request, + agent_id, + agent_config, + context_id, + task_id_config, + metadata, + extensions, + reference_task_ids, + original_task_description, + max_turns, + ) = _prepare_delegation_context( + self, agent_response, task, original_task_description + ) - if hasattr(agent_response, "a2a_ids") and agent_response.a2a_ids: - agent_id = agent_response.a2a_ids[0] - else: - agent_id = agent_ids[0] if agent_ids else "" + conversation_history: list[Message] = [] - if agent_id and agent_id not in agent_ids: - raise ValueError( - f"Unknown A2A agent ID(s): {agent_response.a2a_ids} not in {agent_ids}" + try: + for turn_num in range(max_turns): + console_formatter = getattr(crewai_event_bus, "_console", None) + agent_branch = None + if console_formatter: + agent_branch = getattr( + console_formatter, "current_agent_branch", None + ) or getattr(console_formatter, "current_task_branch", None) + + a2a_result = execute_a2a_delegation( + endpoint=agent_config.endpoint, + auth=agent_config.auth, + timeout=agent_config.timeout, + task_description=current_request, + context_id=context_id, + task_id=task_id_config, + reference_task_ids=reference_task_ids, + metadata=metadata, + extensions=extensions, + conversation_history=conversation_history, + agent_id=agent_id, + agent_role=Role.user, + agent_branch=agent_branch, + response_model=agent_config.response_model, + turn_number=turn_num + 1, + updates=agent_config.updates, + ) + + conversation_history = a2a_result.get("history", []) + + if conversation_history: + latest_message = conversation_history[-1] + if latest_message.task_id is not None: + task_id_config = latest_message.task_id + if latest_message.context_id is not None: + context_id = latest_message.context_id + + if a2a_result["status"] in [TaskState.completed, TaskState.input_required]: + trusted_result, task_id_config, reference_task_ids = ( + _handle_task_completion( + a2a_result, + task, + task_id_config, + reference_task_ids, + agent_config, + turn_num, + ) + ) + if trusted_result is not None: + return trusted_result + + final_result, next_request = _handle_agent_response_and_continue( + self=self, + a2a_result=a2a_result, + agent_id=agent_id, + agent_cards=agent_cards, + a2a_agents=a2a_agents, + original_task_description=original_task_description, + conversation_history=conversation_history, + turn_num=turn_num, + max_turns=max_turns, + task=task, + original_fn=original_fn, + context=context, + tools=tools, + agent_response_model=agent_response_model, + remote_task_completed=(a2a_result["status"] == TaskState.completed), + ) + + if final_result is not None: + return final_result + + if next_request is not None: + current_request = next_request + + continue + + error_msg = a2a_result.get("error", "Unknown error") + + final_result, next_request = _handle_agent_response_and_continue( + self=self, + a2a_result=a2a_result, + agent_id=agent_id, + agent_cards=agent_cards, + a2a_agents=a2a_agents, + original_task_description=original_task_description, + conversation_history=conversation_history, + turn_num=turn_num, + max_turns=max_turns, + task=task, + original_fn=original_fn, + context=context, + tools=tools, + agent_response_model=agent_response_model, + ) + + if final_result is not None: + return final_result + + if next_request is not None: + current_request = next_request + continue + + crewai_event_bus.emit( + None, + A2AConversationCompletedEvent( + status="failed", + final_result=None, + error=error_msg, + total_turns=turn_num + 1, + ), + ) + return f"A2A delegation failed: {error_msg}" + + return _handle_max_turns_exceeded(conversation_history, max_turns) + + finally: + task.description = original_task_description + + +async def _afetch_card_from_config( + config: A2AConfig, +) -> tuple[A2AConfig, AgentCard | Exception]: + """Fetch agent card from A2A config asynchronously.""" + try: + card = await afetch_agent_card( + endpoint=config.endpoint, + auth=config.auth, + timeout=config.timeout, ) + return config, card + except Exception as e: + return config, e - agent_config = next(filter(lambda x: x.endpoint == agent_id, a2a_agents)) - task_config = task.config or {} - context_id = task_config.get("context_id") - task_id_config = task_config.get("task_id") - metadata = task_config.get("metadata") - extensions = task_config.get("extensions") - reference_task_ids = task_config.get("reference_task_ids", []) +async def _afetch_agent_cards_concurrently( + a2a_agents: list[A2AConfig], +) -> tuple[dict[str, AgentCard], dict[str, str]]: + """Fetch agent cards concurrently for multiple A2A agents using asyncio.""" + agent_cards: dict[str, AgentCard] = {} + failed_agents: dict[str, str] = {} + + tasks = [_afetch_card_from_config(config) for config in a2a_agents] + results = await asyncio.gather(*tasks) + + for config, result in results: + if isinstance(result, Exception): + if config.fail_fast: + raise RuntimeError( + f"Failed to fetch agent card from {config.endpoint}. " + f"Ensure the A2A agent is running and accessible. Error: {result}" + ) from result + failed_agents[config.endpoint] = str(result) + else: + agent_cards[config.endpoint] = result + + return agent_cards, failed_agents + + +async def _aexecute_task_with_a2a( + self: Agent, + a2a_agents: list[A2AConfig], + original_fn: Callable[..., Coroutine[Any, Any, str]], + task: Task, + agent_response_model: type[BaseModel], + context: str | None, + tools: list[BaseTool] | None, + extension_registry: ExtensionRegistry, +) -> str: + """Async version of _execute_task_with_a2a.""" + original_description: str = task.description + original_output_pydantic = task.output_pydantic + original_response_model = task.response_model + + agent_cards, failed_agents = await _afetch_agent_cards_concurrently(a2a_agents) + + if not agent_cards and a2a_agents and failed_agents: + unavailable_agents_text = "" + for endpoint, error in failed_agents.items(): + unavailable_agents_text += f" - {endpoint}: {error}\n" + + notice = UNAVAILABLE_AGENTS_NOTICE_TEMPLATE.substitute( + unavailable_agents=unavailable_agents_text + ) + task.description = f"{original_description}{notice}" + + try: + return await original_fn(self, task, context, tools) + finally: + task.description = original_description + + task.description, _ = _augment_prompt_with_a2a( + a2a_agents=a2a_agents, + task_description=original_description, + agent_cards=agent_cards, + failed_agents=failed_agents, + extension_registry=extension_registry, + ) + task.response_model = agent_response_model + + try: + raw_result = await original_fn(self, task, context, tools) + agent_response = _parse_agent_response( + raw_result=raw_result, agent_response_model=agent_response_model + ) + + if extension_registry and isinstance(agent_response, BaseModel): + agent_response = extension_registry.process_response_with_all( + agent_response, {} + ) + + if isinstance(agent_response, BaseModel) and isinstance( + agent_response, AgentResponseProtocol + ): + if agent_response.is_a2a: + return await _adelegate_to_a2a( + self, + agent_response=agent_response, + task=task, + original_fn=original_fn, + context=context, + tools=tools, + agent_cards=agent_cards, + original_task_description=original_description, + extension_registry=extension_registry, + ) + return str(agent_response.message) + + return raw_result + finally: + task.description = original_description + task.output_pydantic = original_output_pydantic + task.response_model = original_response_model - if original_task_description is None: - original_task_description = task.description + +async def _ahandle_agent_response_and_continue( + self: Agent, + a2a_result: TaskStateResult, + agent_id: str, + agent_cards: dict[str, AgentCard] | None, + a2a_agents: list[A2AConfig], + original_task_description: str, + conversation_history: list[Message], + turn_num: int, + max_turns: int, + task: Task, + original_fn: Callable[..., Coroutine[Any, Any, str]], + context: str | None, + tools: list[BaseTool] | None, + agent_response_model: type[BaseModel], + remote_task_completed: bool = False, +) -> tuple[str | None, str | None]: + """Async version of _handle_agent_response_and_continue.""" + agent_cards_dict = _prepare_agent_cards_dict(a2a_result, agent_id, agent_cards) + + task.description, disable_structured_output = _augment_prompt_with_a2a( + a2a_agents=a2a_agents, + task_description=original_task_description, + conversation_history=conversation_history, + turn_num=turn_num, + max_turns=max_turns, + agent_cards=agent_cards_dict, + remote_task_completed=remote_task_completed, + ) + + original_response_model = task.response_model + if disable_structured_output: + task.response_model = None + + raw_result = await original_fn(self, task, context, tools) + + if disable_structured_output: + task.response_model = original_response_model + + return _process_response_result( + raw_result=raw_result, + disable_structured_output=disable_structured_output, + turn_num=turn_num, + agent_role=self.role, + agent_response_model=agent_response_model, + ) + + +async def _adelegate_to_a2a( + self: Agent, + agent_response: AgentResponseProtocol, + task: Task, + original_fn: Callable[..., Coroutine[Any, Any, str]], + context: str | None, + tools: list[BaseTool] | None, + agent_cards: dict[str, AgentCard] | None = None, + original_task_description: str | None = None, + extension_registry: ExtensionRegistry | None = None, +) -> str: + """Async version of _delegate_to_a2a.""" + ( + a2a_agents, + agent_response_model, + current_request, + agent_id, + agent_config, + context_id, + task_id_config, + metadata, + extensions, + reference_task_ids, + original_task_description, + max_turns, + ) = _prepare_delegation_context( + self, agent_response, task, original_task_description + ) conversation_history: list[Message] = [] - max_turns = agent_config.max_turns try: for turn_num in range(max_turns): @@ -560,7 +1068,7 @@ def _delegate_to_a2a( console_formatter, "current_agent_branch", None ) or getattr(console_formatter, "current_task_branch", None) - a2a_result = execute_a2a_delegation( + a2a_result = await aexecute_a2a_delegation( endpoint=agent_config.endpoint, auth=agent_config.auth, timeout=agent_config.timeout, @@ -589,35 +1097,20 @@ def _delegate_to_a2a( context_id = latest_message.context_id if a2a_result["status"] in [TaskState.completed, TaskState.input_required]: - if a2a_result["status"] == TaskState.completed: - if ( - task_id_config is not None - and task_id_config not in reference_task_ids - ): - reference_task_ids.append(task_id_config) - if task.config is None: - task.config = {} - task.config["reference_task_ids"] = reference_task_ids - task_id_config = None - - if ( - a2a_result["status"] == TaskState.completed - and agent_config.trust_remote_completion_status - ): - result_text = a2a_result.get("result", "") - final_turn_number = turn_num + 1 - crewai_event_bus.emit( - None, - A2AConversationCompletedEvent( - status="completed", - final_result=result_text, - error=None, - total_turns=final_turn_number, - ), + trusted_result, task_id_config, reference_task_ids = ( + _handle_task_completion( + a2a_result, + task, + task_id_config, + reference_task_ids, + agent_config, + turn_num, ) - return str(result_text) + ) + if trusted_result is not None: + return trusted_result - final_result, next_request = _handle_agent_response_and_continue( + final_result, next_request = await _ahandle_agent_response_and_continue( self=self, a2a_result=a2a_result, agent_id=agent_id, @@ -645,7 +1138,7 @@ def _delegate_to_a2a( error_msg = a2a_result.get("error", "Unknown error") - final_result, next_request = _handle_agent_response_and_continue( + final_result, next_request = await _ahandle_agent_response_and_continue( self=self, a2a_result=a2a_result, agent_id=agent_id, @@ -660,7 +1153,6 @@ def _delegate_to_a2a( context=context, tools=tools, agent_response_model=agent_response_model, - remote_task_completed=False, ) if final_result is not None: @@ -681,36 +1173,7 @@ def _delegate_to_a2a( ) return f"A2A delegation failed: {error_msg}" - if conversation_history: - for msg in reversed(conversation_history): - if msg.role == Role.agent: - text_parts = [ - part.root.text for part in msg.parts if part.root.kind == "text" - ] - final_message = ( - " ".join(text_parts) if text_parts else "Conversation completed" - ) - crewai_event_bus.emit( - None, - A2AConversationCompletedEvent( - status="completed", - final_result=final_message, - error=None, - total_turns=max_turns, - ), - ) - return final_message - - crewai_event_bus.emit( - None, - A2AConversationCompletedEvent( - status="failed", - final_result=None, - error=f"Conversation exceeded maximum turns ({max_turns})", - total_turns=max_turns, - ), - ) - raise Exception(f"A2A conversation exceeded maximum turns ({max_turns})") + return _handle_max_turns_exceeded(conversation_history, max_turns) finally: task.description = original_task_description From 9efb973d5d2150381f85cb2edf4d993ef5cadd11 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 7 Jan 2026 02:45:23 -0500 Subject: [PATCH 27/34] feat: add server agent card generation and organize a2a utils into submodules --- lib/crewai/src/crewai/a2a/utils/__init__.py | 1 + lib/crewai/src/crewai/a2a/utils/agent_card.py | 360 ++++++++++++++++++ .../a2a/{utils.py => utils/delegation.py} | 289 +------------- .../src/crewai/a2a/utils/response_model.py | 82 ++++ lib/crewai/src/crewai/a2a/wrapper.py | 7 +- 5 files changed, 459 insertions(+), 280 deletions(-) create mode 100644 lib/crewai/src/crewai/a2a/utils/__init__.py create mode 100644 lib/crewai/src/crewai/a2a/utils/agent_card.py rename lib/crewai/src/crewai/a2a/{utils.py => utils/delegation.py} (61%) create mode 100644 lib/crewai/src/crewai/a2a/utils/response_model.py diff --git a/lib/crewai/src/crewai/a2a/utils/__init__.py b/lib/crewai/src/crewai/a2a/utils/__init__.py new file mode 100644 index 0000000000..bdb7bed623 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/utils/__init__.py @@ -0,0 +1 @@ +"""A2A utility modules for client operations.""" diff --git a/lib/crewai/src/crewai/a2a/utils/agent_card.py b/lib/crewai/src/crewai/a2a/utils/agent_card.py new file mode 100644 index 0000000000..20e6214734 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/utils/agent_card.py @@ -0,0 +1,360 @@ +"""AgentCard utilities for A2A client and server operations.""" + +from __future__ import annotations + +import asyncio +from collections.abc import MutableMapping +from functools import lru_cache +import time +from types import MethodType +from typing import TYPE_CHECKING + +from a2a.client.errors import A2AClientHTTPError +from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from aiocache import cached # type: ignore[import-untyped] +from aiocache.serializers import PickleSerializer # type: ignore[import-untyped] +import httpx + +from crewai.a2a.auth.schemas import APIKeyAuth, HTTPDigestAuth +from crewai.a2a.auth.utils import ( + _auth_store, + configure_auth_client, + retry_on_401, +) +from crewai.crew import Crew + + +if TYPE_CHECKING: + from crewai.a2a.auth.schemas import AuthScheme + from crewai.agent import Agent + from crewai.task import Task + + +def fetch_agent_card( + endpoint: str, + auth: AuthScheme | None = None, + timeout: int = 30, + use_cache: bool = True, + cache_ttl: int = 300, +) -> AgentCard: + """Fetch AgentCard from an A2A endpoint with optional caching. + + Args: + endpoint: A2A agent endpoint URL (AgentCard URL). + auth: Optional AuthScheme for authentication. + timeout: Request timeout in seconds. + use_cache: Whether to use caching (default True). + cache_ttl: Cache TTL in seconds (default 300 = 5 minutes). + + Returns: + AgentCard object with agent capabilities and skills. + + Raises: + httpx.HTTPStatusError: If the request fails. + A2AClientHTTPError: If authentication fails. + """ + if use_cache: + if auth: + auth_data = auth.model_dump_json( + exclude={ + "_access_token", + "_token_expires_at", + "_refresh_token", + "_authorization_callback", + } + ) + auth_hash = hash((type(auth).__name__, auth_data)) + else: + auth_hash = 0 + _auth_store[auth_hash] = auth + ttl_hash = int(time.time() // cache_ttl) + return _fetch_agent_card_cached(endpoint, auth_hash, timeout, ttl_hash) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete( + afetch_agent_card(endpoint=endpoint, auth=auth, timeout=timeout) + ) + finally: + loop.close() + + +async def afetch_agent_card( + endpoint: str, + auth: AuthScheme | None = None, + timeout: int = 30, + use_cache: bool = True, +) -> AgentCard: + """Fetch AgentCard from an A2A endpoint asynchronously. + + Native async implementation. Use this when running in an async context. + + Args: + endpoint: A2A agent endpoint URL (AgentCard URL). + auth: Optional AuthScheme for authentication. + timeout: Request timeout in seconds. + use_cache: Whether to use caching (default True). + + Returns: + AgentCard object with agent capabilities and skills. + + Raises: + httpx.HTTPStatusError: If the request fails. + A2AClientHTTPError: If authentication fails. + """ + if use_cache: + if auth: + auth_data = auth.model_dump_json( + exclude={ + "_access_token", + "_token_expires_at", + "_refresh_token", + "_authorization_callback", + } + ) + auth_hash = hash((type(auth).__name__, auth_data)) + else: + auth_hash = 0 + _auth_store[auth_hash] = auth + agent_card: AgentCard = await _afetch_agent_card_cached( + endpoint, auth_hash, timeout + ) + return agent_card + + return await _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) + + +@lru_cache() +def _fetch_agent_card_cached( + endpoint: str, + auth_hash: int, + timeout: int, + _ttl_hash: int, +) -> AgentCard: + """Cached sync version of fetch_agent_card.""" + auth = _auth_store.get(auth_hash) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete( + _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) + ) + finally: + loop.close() + + +@cached(ttl=300, serializer=PickleSerializer()) # type: ignore[untyped-decorator] +async def _afetch_agent_card_cached( + endpoint: str, + auth_hash: int, + timeout: int, +) -> AgentCard: + """Cached async implementation of AgentCard fetching.""" + auth = _auth_store.get(auth_hash) + return await _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) + + +async def _afetch_agent_card_impl( + endpoint: str, + auth: AuthScheme | None, + timeout: int, +) -> AgentCard: + """Internal async implementation of AgentCard fetching.""" + if "/.well-known/agent-card.json" in endpoint: + base_url = endpoint.replace("/.well-known/agent-card.json", "") + agent_card_path = "/.well-known/agent-card.json" + else: + url_parts = endpoint.split("/", 3) + base_url = f"{url_parts[0]}//{url_parts[2]}" + agent_card_path = f"/{url_parts[3]}" if len(url_parts) > 3 else "/" + + headers: MutableMapping[str, str] = {} + if auth: + async with httpx.AsyncClient(timeout=timeout) as temp_auth_client: + if isinstance(auth, (HTTPDigestAuth, APIKeyAuth)): + configure_auth_client(auth, temp_auth_client) + headers = await auth.apply_auth(temp_auth_client, {}) + + async with httpx.AsyncClient(timeout=timeout, headers=headers) as temp_client: + if auth and isinstance(auth, (HTTPDigestAuth, APIKeyAuth)): + configure_auth_client(auth, temp_client) + + agent_card_url = f"{base_url}{agent_card_path}" + + async def _fetch_agent_card_request() -> httpx.Response: + return await temp_client.get(agent_card_url) + + try: + response = await retry_on_401( + request_func=_fetch_agent_card_request, + auth_scheme=auth, + client=temp_client, + headers=temp_client.headers, + max_retries=2, + ) + response.raise_for_status() + + return AgentCard.model_validate(response.json()) + + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + error_details = ["Authentication failed"] + www_auth = e.response.headers.get("WWW-Authenticate") + if www_auth: + error_details.append(f"WWW-Authenticate: {www_auth}") + if not auth: + error_details.append("No auth scheme provided") + msg = " | ".join(error_details) + raise A2AClientHTTPError(401, msg) from e + raise + + +def _task_to_skill(task: Task) -> AgentSkill: + """Convert a CrewAI Task to an A2A AgentSkill. + + Args: + task: The CrewAI Task to convert. + + Returns: + AgentSkill representing the task's capability. + """ + task_name = task.name or task.description[:50] + task_id = task_name.lower().replace(" ", "_") + + tags: list[str] = [] + if task.agent: + tags.append(task.agent.role.lower().replace(" ", "-")) + + return AgentSkill( + id=task_id, + name=task_name, + description=task.description, + tags=tags, + examples=[task.expected_output] if task.expected_output else None, + ) + + +def _tool_to_skill(tool_name: str, tool_description: str) -> AgentSkill: + """Convert an Agent's tool to an A2A AgentSkill. + + Args: + tool_name: Name of the tool. + tool_description: Description of what the tool does. + + Returns: + AgentSkill representing the tool's capability. + """ + tool_id = tool_name.lower().replace(" ", "_") + + return AgentSkill( + id=tool_id, + name=tool_name, + description=tool_description, + tags=[tool_name.lower().replace(" ", "-")], + ) + + +def _crew_to_agent_card(crew: Crew, url: str) -> AgentCard: + """Generate an A2A AgentCard from a Crew instance. + + Args: + crew: The Crew instance to generate a card for. + url: The base URL where this crew will be exposed. + + Returns: + AgentCard describing the crew's capabilities. + """ + crew_name = getattr(crew, "name", None) or crew.__class__.__name__ + + description_parts: list[str] = [] + crew_description = getattr(crew, "description", None) + if crew_description: + description_parts.append(crew_description) + else: + agent_roles = [agent.role for agent in crew.agents] + description_parts.append( + f"A crew of {len(crew.agents)} agents: {', '.join(agent_roles)}" + ) + + skills = [_task_to_skill(task) for task in crew.tasks] + + return AgentCard( + name=crew_name, + description=" ".join(description_parts), + url=url, + version="1.0.0", + capabilities=AgentCapabilities( + streaming=True, + push_notifications=True, + ), + default_input_modes=["text/plain", "application/json"], + default_output_modes=["text/plain", "application/json"], + skills=skills, + ) + + +def _agent_to_agent_card(agent: Agent, url: str) -> AgentCard: + """Generate an A2A AgentCard from an Agent instance. + + Args: + agent: The Agent instance to generate a card for. + url: The base URL where this agent will be exposed. + + Returns: + AgentCard describing the agent's capabilities. + """ + description_parts = [agent.goal] + if agent.backstory: + description_parts.append(agent.backstory) + + skills: list[AgentSkill] = [] + + if agent.tools: + for tool in agent.tools: + tool_name = getattr(tool, "name", None) or tool.__class__.__name__ + tool_desc = getattr(tool, "description", None) or f"Tool: {tool_name}" + skills.append(_tool_to_skill(tool_name, tool_desc)) + + if not skills: + skills.append( + AgentSkill( + id=agent.role.lower().replace(" ", "_"), + name=agent.role, + description=agent.goal, + tags=[agent.role.lower().replace(" ", "-")], + ) + ) + + return AgentCard( + name=agent.role, + description=" ".join(description_parts), + url=url, + version="1.0.0", + capabilities=AgentCapabilities( + streaming=True, + push_notifications=True, + ), + default_input_modes=["text/plain", "application/json"], + default_output_modes=["text/plain", "application/json"], + skills=skills, + ) + + +def inject_a2a_server_methods(target: Crew | Agent) -> None: + """Inject A2A server methods onto a Crew or Agent instance. + + Adds a `to_agent_card(url: str) -> AgentCard` method to the target + instance that generates an A2A-compliant AgentCard. + + Args: + target: The Crew or Agent instance to inject methods onto. + """ + + def _to_agent_card(self: Crew | Agent, url: str) -> AgentCard: + if isinstance(self, Crew): + return _crew_to_agent_card(self, url) + return _agent_to_agent_card(self, url) + + target.to_agent_card = MethodType(_to_agent_card, target) # type: ignore[union-attr] diff --git a/lib/crewai/src/crewai/a2a/utils.py b/lib/crewai/src/crewai/a2a/utils/delegation.py similarity index 61% rename from lib/crewai/src/crewai/a2a/utils.py rename to lib/crewai/src/crewai/a2a/utils/delegation.py index 4b3ba23e9e..ac65a60888 100644 --- a/lib/crewai/src/crewai/a2a/utils.py +++ b/lib/crewai/src/crewai/a2a/utils/delegation.py @@ -1,16 +1,14 @@ -"""Utility functions for A2A (Agent-to-Agent) protocol delegation.""" +"""A2A delegation utilities for executing tasks on remote agents.""" from __future__ import annotations import asyncio from collections.abc import AsyncIterator, MutableMapping from contextlib import asynccontextmanager -from functools import lru_cache -import time from typing import TYPE_CHECKING, Any import uuid -from a2a.client import A2AClientHTTPError, Client, ClientConfig, ClientFactory +from a2a.client import Client, ClientConfig, ClientFactory from a2a.types import ( AgentCard, Message, @@ -20,19 +18,15 @@ TextPart, TransportProtocol, ) -from aiocache import cached # type: ignore[import-untyped] -from aiocache.serializers import PickleSerializer # type: ignore[import-untyped] import httpx -from pydantic import BaseModel, Field, create_model +from pydantic import BaseModel from crewai.a2a.auth.schemas import APIKeyAuth, HTTPDigestAuth from crewai.a2a.auth.utils import ( _auth_store, configure_auth_client, - retry_on_401, validate_auth_against_agent_card, ) -from crewai.a2a.config import A2AConfig from crewai.a2a.task_helpers import TaskStateResult from crewai.a2a.types import ( HANDLER_REGISTRY, @@ -46,6 +40,7 @@ StreamingHandler, UpdateConfig, ) +from crewai.a2a.utils.agent_card import _afetch_agent_card_cached from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import ( A2AConversationStartedEvent, @@ -53,7 +48,6 @@ A2ADelegationStartedEvent, A2AMessageSentEvent, ) -from crewai.types.utils import create_literals_from_strings if TYPE_CHECKING: @@ -76,187 +70,6 @@ def get_handler(config: UpdateConfig | None) -> HandlerType: return HANDLER_REGISTRY.get(type(config), StreamingHandler) -@lru_cache() -def _fetch_agent_card_cached( - endpoint: str, - auth_hash: int, - timeout: int, - _ttl_hash: int, -) -> AgentCard: - """Cached sync version of fetch_agent_card.""" - auth = _auth_store.get(auth_hash) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - return loop.run_until_complete( - _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) - ) - finally: - loop.close() - - -def fetch_agent_card( - endpoint: str, - auth: AuthScheme | None = None, - timeout: int = 30, - use_cache: bool = True, - cache_ttl: int = 300, -) -> AgentCard: - """Fetch AgentCard from an A2A endpoint with optional caching. - - Args: - endpoint: A2A agent endpoint URL (AgentCard URL) - auth: Optional AuthScheme for authentication - timeout: Request timeout in seconds - use_cache: Whether to use caching (default True) - cache_ttl: Cache TTL in seconds (default 300 = 5 minutes) - - Returns: - AgentCard object with agent capabilities and skills - - Raises: - httpx.HTTPStatusError: If the request fails - A2AClientHTTPError: If authentication fails - """ - if use_cache: - if auth: - auth_data = auth.model_dump_json( - exclude={ - "_access_token", - "_token_expires_at", - "_refresh_token", - "_authorization_callback", - } - ) - auth_hash = hash((type(auth).__name__, auth_data)) - else: - auth_hash = 0 - _auth_store[auth_hash] = auth - ttl_hash = int(time.time() // cache_ttl) - return _fetch_agent_card_cached(endpoint, auth_hash, timeout, ttl_hash) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - return loop.run_until_complete( - afetch_agent_card(endpoint=endpoint, auth=auth, timeout=timeout) - ) - finally: - loop.close() - - -async def afetch_agent_card( - endpoint: str, - auth: AuthScheme | None = None, - timeout: int = 30, - use_cache: bool = True, -) -> AgentCard: - """Fetch AgentCard from an A2A endpoint asynchronously. - - Native async implementation. Use this when running in an async context. - - Args: - endpoint: A2A agent endpoint URL (AgentCard URL). - auth: Optional AuthScheme for authentication. - timeout: Request timeout in seconds. - use_cache: Whether to use caching (default True). - - Returns: - AgentCard object with agent capabilities and skills. - - Raises: - httpx.HTTPStatusError: If the request fails. - A2AClientHTTPError: If authentication fails. - """ - if use_cache: - if auth: - auth_data = auth.model_dump_json( - exclude={ - "_access_token", - "_token_expires_at", - "_refresh_token", - "_authorization_callback", - } - ) - auth_hash = hash((type(auth).__name__, auth_data)) - else: - auth_hash = 0 - _auth_store[auth_hash] = auth - agent_card: AgentCard = await _afetch_agent_card_cached( - endpoint, auth_hash, timeout - ) - return agent_card - - return await _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) - - -@cached(ttl=300, serializer=PickleSerializer()) # type: ignore[untyped-decorator] -async def _afetch_agent_card_cached( - endpoint: str, - auth_hash: int, - timeout: int, -) -> AgentCard: - """Cached async implementation of AgentCard fetching.""" - auth = _auth_store.get(auth_hash) - return await _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout) - - -async def _afetch_agent_card_impl( - endpoint: str, - auth: AuthScheme | None, - timeout: int, -) -> AgentCard: - """Internal async implementation of AgentCard fetching.""" - if "/.well-known/agent-card.json" in endpoint: - base_url = endpoint.replace("/.well-known/agent-card.json", "") - agent_card_path = "/.well-known/agent-card.json" - else: - url_parts = endpoint.split("/", 3) - base_url = f"{url_parts[0]}//{url_parts[2]}" - agent_card_path = f"/{url_parts[3]}" if len(url_parts) > 3 else "/" - - headers: MutableMapping[str, str] = {} - if auth: - async with httpx.AsyncClient(timeout=timeout) as temp_auth_client: - if isinstance(auth, (HTTPDigestAuth, APIKeyAuth)): - configure_auth_client(auth, temp_auth_client) - headers = await auth.apply_auth(temp_auth_client, {}) - - async with httpx.AsyncClient(timeout=timeout, headers=headers) as temp_client: - if auth and isinstance(auth, (HTTPDigestAuth, APIKeyAuth)): - configure_auth_client(auth, temp_client) - - agent_card_url = f"{base_url}{agent_card_path}" - - async def _fetch_agent_card_request() -> httpx.Response: - return await temp_client.get(agent_card_url) - - try: - response = await retry_on_401( - request_func=_fetch_agent_card_request, - auth_scheme=auth, - client=temp_client, - headers=temp_client.headers, - max_retries=2, - ) - response.raise_for_status() - - return AgentCard.model_validate(response.json()) - - except httpx.HTTPStatusError as e: - if e.response.status_code == 401: - error_details = ["Authentication failed"] - www_auth = e.response.headers.get("WWW-Authenticate") - if www_auth: - error_details.append(f"WWW-Authenticate: {www_auth}") - if not auth: - error_details.append("No auth scheme provided") - msg = " | ".join(error_details) - raise A2AClientHTTPError(401, msg) from e - raise - - def execute_a2a_delegation( endpoint: str, auth: AuthScheme | None, @@ -607,19 +420,18 @@ async def _create_a2a_client( """Create and configure an A2A client. Args: - agent_card: The A2A agent card - transport_protocol: Transport protocol to use - timeout: Request timeout in seconds - headers: HTTP headers (already with auth applied) - streaming: Enable streaming responses - auth: Optional AuthScheme for client configuration - use_polling: Enable polling mode - push_notification_config: Optional push notification config to include in requests + agent_card: The A2A agent card. + transport_protocol: Transport protocol to use. + timeout: Request timeout in seconds. + headers: HTTP headers (already with auth applied). + streaming: Enable streaming responses. + auth: Optional AuthScheme for client configuration. + use_polling: Enable polling mode. + push_notification_config: Optional push notification config. Yields: - Configured A2A client instance + Configured A2A client instance. """ - async with httpx.AsyncClient( timeout=timeout, headers=headers, @@ -650,78 +462,3 @@ async def _create_a2a_client( factory = ClientFactory(config) client = factory.create(agent_card) yield client - - -def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel]: - """Create a dynamic AgentResponse model with Literal types for agent IDs. - - Args: - agent_ids: List of available A2A agent IDs - - Returns: - Dynamically created Pydantic model with Literal-constrained a2a_ids field - """ - - DynamicLiteral = create_literals_from_strings(agent_ids) # noqa: N806 - - return create_model( - "AgentResponse", - a2a_ids=( - tuple[DynamicLiteral, ...], # type: ignore[valid-type] - Field( - default_factory=tuple, - max_length=len(agent_ids), - description="A2A agent IDs to delegate to.", - ), - ), - message=( - str, - Field( - description="The message content. If is_a2a=true, this is sent to the A2A agent. If is_a2a=false, this is your final answer ending the conversation." - ), - ), - is_a2a=( - bool, - Field( - description="Set to false when the remote agent has answered your question - extract their answer and return it as your final message. Set to true ONLY if you need to ask a NEW, DIFFERENT question. NEVER repeat the same request - if the conversation history shows the agent already answered, set is_a2a=false immediately." - ), - ), - __base__=BaseModel, - ) - - -def extract_a2a_agent_ids_from_config( - a2a_config: list[A2AConfig] | A2AConfig | None, -) -> tuple[list[A2AConfig], tuple[str, ...]]: - """Extract A2A agent IDs from A2A configuration. - - Args: - a2a_config: A2A configuration - - Returns: - List of A2A agent IDs - """ - if a2a_config is None: - return [], () - - if isinstance(a2a_config, A2AConfig): - a2a_agents = [a2a_config] - else: - a2a_agents = a2a_config - return a2a_agents, tuple(config.endpoint for config in a2a_agents) - - -def get_a2a_agents_and_response_model( - a2a_config: list[A2AConfig] | A2AConfig | None, -) -> tuple[list[A2AConfig], type[BaseModel]]: - """Get A2A agent IDs and response model. - - Args: - a2a_config: A2A configuration - - Returns: - Tuple of A2A agent IDs and response model - """ - a2a_agents, agent_ids = extract_a2a_agent_ids_from_config(a2a_config=a2a_config) - - return a2a_agents, create_agent_response_model(agent_ids) diff --git a/lib/crewai/src/crewai/a2a/utils/response_model.py b/lib/crewai/src/crewai/a2a/utils/response_model.py new file mode 100644 index 0000000000..9da0f35ea5 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/utils/response_model.py @@ -0,0 +1,82 @@ +"""Response model utilities for A2A agent interactions.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field, create_model + +from crewai.a2a.config import A2AConfig +from crewai.types.utils import create_literals_from_strings + + +def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel]: + """Create a dynamic AgentResponse model with Literal types for agent IDs. + + Args: + agent_ids: List of available A2A agent IDs. + + Returns: + Dynamically created Pydantic model with Literal-constrained a2a_ids field. + """ + DynamicLiteral = create_literals_from_strings(agent_ids) # noqa: N806 + + return create_model( + "AgentResponse", + a2a_ids=( + tuple[DynamicLiteral, ...], # type: ignore[valid-type] + Field( + default_factory=tuple, + max_length=len(agent_ids), + description="A2A agent IDs to delegate to.", + ), + ), + message=( + str, + Field( + description="The message content. If is_a2a=true, this is sent to the A2A agent. If is_a2a=false, this is your final answer ending the conversation." + ), + ), + is_a2a=( + bool, + Field( + description="Set to false when the remote agent has answered your question - extract their answer and return it as your final message. Set to true ONLY if you need to ask a NEW, DIFFERENT question. NEVER repeat the same request - if the conversation history shows the agent already answered, set is_a2a=false immediately." + ), + ), + __base__=BaseModel, + ) + + +def extract_a2a_agent_ids_from_config( + a2a_config: list[A2AConfig] | A2AConfig | None, +) -> tuple[list[A2AConfig], tuple[str, ...]]: + """Extract A2A agent IDs from A2A configuration. + + Args: + a2a_config: A2A configuration. + + Returns: + Tuple of A2A configs list and agent endpoint IDs. + """ + if a2a_config is None: + return [], () + + if isinstance(a2a_config, A2AConfig): + a2a_agents = [a2a_config] + else: + a2a_agents = a2a_config + return a2a_agents, tuple(config.endpoint for config in a2a_agents) + + +def get_a2a_agents_and_response_model( + a2a_config: list[A2AConfig] | A2AConfig | None, +) -> tuple[list[A2AConfig], type[BaseModel]]: + """Get A2A agent configs and response model. + + Args: + a2a_config: A2A configuration. + + Returns: + Tuple of A2A configs and response model. + """ + a2a_agents, agent_ids = extract_a2a_agent_ids_from_config(a2a_config=a2a_config) + + return a2a_agents, create_agent_response_model(agent_ids) diff --git a/lib/crewai/src/crewai/a2a/wrapper.py b/lib/crewai/src/crewai/a2a/wrapper.py index fc4cdc2c63..29c69ddfe9 100644 --- a/lib/crewai/src/crewai/a2a/wrapper.py +++ b/lib/crewai/src/crewai/a2a/wrapper.py @@ -26,13 +26,12 @@ UNAVAILABLE_AGENTS_NOTICE_TEMPLATE, ) from crewai.a2a.types import AgentResponseProtocol -from crewai.a2a.utils import ( +from crewai.a2a.utils.agent_card import afetch_agent_card, fetch_agent_card +from crewai.a2a.utils.delegation import ( aexecute_a2a_delegation, - afetch_agent_card, execute_a2a_delegation, - fetch_agent_card, - get_a2a_agents_and_response_model, ) +from crewai.a2a.utils.response_model import get_a2a_agents_and_response_model from crewai.events.event_bus import crewai_event_bus from crewai.events.types.a2a_events import ( A2AConversationCompletedEvent, From 480ac980f3bc4fb32ae382667fcb49c69512627a Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 7 Jan 2026 05:35:53 -0500 Subject: [PATCH 28/34] feat: add server and client configs, deprecate unified --- lib/crewai/src/crewai/a2a/config.py | 167 ++++++++++++++++++++++++---- lib/crewai/src/crewai/a2a/types.py | 15 ++- 2 files changed, 162 insertions(+), 20 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/config.py b/lib/crewai/src/crewai/a2a/config.py index d7ac85bae7..80eb1c21e1 100644 --- a/lib/crewai/src/crewai/a2a/config.py +++ b/lib/crewai/src/crewai/a2a/config.py @@ -5,34 +5,31 @@ from __future__ import annotations -from typing import Annotated, ClassVar - -from pydantic import ( - BaseModel, - BeforeValidator, - ConfigDict, - Field, - HttpUrl, - TypeAdapter, +from importlib.metadata import version +from typing import ClassVar + +from a2a.types import ( + AgentCapabilities, + AgentInterface, + AgentProvider, + AgentSkill, + SecurityScheme, ) +from pydantic import BaseModel, ConfigDict, Field +from typing_extensions import deprecated from crewai.a2a.auth.schemas import AuthScheme +from crewai.a2a.types import TransportType, Url from crewai.a2a.updates import StreamingConfig, UpdateConfig -http_url_adapter = TypeAdapter(HttpUrl) - -Url = Annotated[ - str, - BeforeValidator( - lambda value: str(http_url_adapter.validate_python(value, strict=True)) - ), -] - - +@deprecated("Use A2AClientConfig instead.") class A2AConfig(BaseModel): """Configuration for A2A protocol integration. + Deprecated: + Use A2AClientConfig instead. This class will be removed in a future version. + Attributes: endpoint: A2A agent endpoint URL. auth: Authentication scheme. @@ -71,3 +68,135 @@ class A2AConfig(BaseModel): default_factory=StreamingConfig, description="Update mechanism config", ) + + +class A2AClientConfig(A2AConfig): + """Configuration for connecting to remote A2A agents. + + Attributes: + endpoint: A2A agent endpoint URL. + auth: Authentication scheme. + timeout: Request timeout in seconds. + max_turns: Maximum conversation turns with A2A agent. + response_model: Optional Pydantic model for structured A2A agent responses. + fail_fast: If True, raise error when agent unreachable; if False, skip and continue. + trust_remote_completion_status: If True, return A2A agent's result directly when completed. + updates: Update mechanism config. + accepted_output_modes: Media types the client can accept in responses. + supported_transports: Ordered list of transport protocols the client supports. + use_client_preference: Whether to prioritize client transport preferences over server. + extensions: Extension URIs the client supports. + """ + + accepted_output_modes: list[str] = Field( + default_factory=lambda: ["application/json"], + description="Media types the client can accept in responses", + ) + supported_transports: list[str] = Field( + default_factory=lambda: ["JSONRPC"], + description="Ordered list of transport protocols the client supports", + ) + use_client_preference: bool = Field( + default=False, + description="Whether to prioritize client transport preferences over server", + ) + extensions: list[str] = Field( + default_factory=list, + description="Extension URIs the client supports", + ) + + +class A2AServerConfig(BaseModel): + """Configuration for exposing a Crew or Agent as an A2A server. + + All fields correspond to A2A AgentCard fields. Fields like name, description, + and skills can be auto-derived from the Crew/Agent if not provided. + + Attributes: + name: Human-readable name for the agent. + description: Human-readable description of the agent. + version: Version string for the agent card. + skills: List of agent skills/capabilities. + default_input_modes: Default supported input MIME types. + default_output_modes: Default supported output MIME types. + capabilities: Declaration of optional capabilities. + preferred_transport: Transport protocol for the preferred endpoint. + protocol_version: A2A protocol version this agent supports. + provider: Information about the agent's service provider. + documentation_url: URL to the agent's documentation. + icon_url: URL to an icon for the agent. + additional_interfaces: Additional supported interfaces. + security: Security requirement objects for all interactions. + security_schemes: Security schemes available to authorize requests. + supports_authenticated_extended_card: Whether agent provides extended card to authenticated users. + """ + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + name: str | None = Field( + default=None, + description="Human-readable name for the agent. Auto-derived from Crew/Agent if not provided.", + ) + description: str | None = Field( + default=None, + description="Human-readable description of the agent. Auto-derived from Crew/Agent if not provided.", + ) + version: str = Field( + default="1.0.0", + description="Version string for the agent card", + ) + skills: list[AgentSkill] = Field( + default_factory=list, + description="List of agent skills. Auto-derived from tasks/tools if not provided.", + ) + default_input_modes: list[str] = Field( + default_factory=lambda: ["text/plain", "application/json"], + description="Default supported input MIME types", + ) + default_output_modes: list[str] = Field( + default_factory=lambda: ["text/plain", "application/json"], + description="Default supported output MIME types", + ) + capabilities: AgentCapabilities = Field( + default_factory=lambda: AgentCapabilities( + streaming=True, + push_notifications=True, + ), + description="Declaration of optional capabilities supported by the agent", + ) + preferred_transport: TransportType = Field( + default="JSONRPC", + description="Transport protocol for the preferred endpoint", + ) + protocol_version: str = Field( + default_factory=lambda: version("a2a-sdk"), + description="A2A protocol version this agent supports", + ) + provider: AgentProvider | None = Field( + default=None, + description="Information about the agent's service provider", + ) + documentation_url: str | None = Field( + default=None, + description="URL to the agent's documentation", + ) + icon_url: str | None = Field( + default=None, + description="URL to an icon for the agent", + ) + additional_interfaces: list[AgentInterface] = Field( + default_factory=list, + description="Additional supported interfaces (transport and URL combinations)", + ) + security: list[dict[str, list[str]]] = Field( + default_factory=list, + description="Security requirement objects for all agent interactions", + ) + security_schemes: dict[str, SecurityScheme] = Field( + default_factory=dict, + description="Security schemes available to authorize requests", + ) + supports_authenticated_extended_card: bool = Field( + default=False, + description="Whether agent provides extended card to authenticated users", + ) diff --git a/lib/crewai/src/crewai/a2a/types.py b/lib/crewai/src/crewai/a2a/types.py index 217b59467c..2cd318e429 100644 --- a/lib/crewai/src/crewai/a2a/types.py +++ b/lib/crewai/src/crewai/a2a/types.py @@ -1,7 +1,8 @@ """Type definitions for A2A protocol message parts.""" -from typing import Any, Literal, Protocol, TypedDict, runtime_checkable +from typing import Annotated, Any, Literal, Protocol, TypedDict, runtime_checkable +from pydantic import BeforeValidator, HttpUrl, TypeAdapter from typing_extensions import NotRequired from crewai.a2a.updates import ( @@ -15,6 +16,18 @@ ) +TransportType = Literal["JSONRPC", "GRPC", "HTTP+JSON"] + +http_url_adapter: TypeAdapter[HttpUrl] = TypeAdapter(HttpUrl) + +Url = Annotated[ + str, + BeforeValidator( + lambda value: str(http_url_adapter.validate_python(value, strict=True)) + ), +] + + @runtime_checkable class AgentResponseProtocol(Protocol): """Protocol for the dynamically created AgentResponse model.""" From 2de21a075b60eab4aed1e12203a389062fb7049a Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 7 Jan 2026 17:10:47 -0500 Subject: [PATCH 29/34] feat: generate agent card from server config or agent --- lib/crewai/src/crewai/a2a/config.py | 29 ++++++- lib/crewai/src/crewai/a2a/utils/agent_card.py | 87 ++++++++++++++----- 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/config.py b/lib/crewai/src/crewai/a2a/config.py index 5f2ed1cbbb..b0ac834054 100644 --- a/lib/crewai/src/crewai/a2a/config.py +++ b/lib/crewai/src/crewai/a2a/config.py @@ -81,7 +81,7 @@ class A2AConfig(BaseModel): ) -class A2AClientConfig(A2AConfig): +class A2AClientConfig(BaseModel): """Configuration for connecting to remote A2A agents. Attributes: @@ -99,6 +99,33 @@ class A2AClientConfig(A2AConfig): extensions: Extension URIs the client supports. """ + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + endpoint: Url = Field(description="A2A agent endpoint URL") + auth: AuthScheme | None = Field( + default=None, + description="Authentication scheme", + ) + timeout: int = Field(default=120, description="Request timeout in seconds") + max_turns: int = Field( + default=10, description="Maximum conversation turns with A2A agent" + ) + response_model: type[BaseModel] | None = Field( + default=None, + description="Optional Pydantic model for structured A2A agent responses", + ) + fail_fast: bool = Field( + default=True, + description="If True, raise error when agent unreachable; if False, skip", + ) + trust_remote_completion_status: bool = Field( + default=False, + description="If True, return A2A result directly when completed", + ) + updates: UpdateConfig = Field( + default_factory=_get_default_update_config, + description="Update mechanism config", + ) accepted_output_modes: list[str] = Field( default_factory=lambda: ["application/json"], description="Media types the client can accept in responses", diff --git a/lib/crewai/src/crewai/a2a/utils/agent_card.py b/lib/crewai/src/crewai/a2a/utils/agent_card.py index 20e6214734..bd8c4db659 100644 --- a/lib/crewai/src/crewai/a2a/utils/agent_card.py +++ b/lib/crewai/src/crewai/a2a/utils/agent_card.py @@ -21,6 +21,7 @@ configure_auth_client, retry_on_401, ) +from crewai.a2a.config import A2AServerConfig from crewai.crew import Crew @@ -30,6 +31,26 @@ from crewai.task import Task +def _get_server_config(agent: Agent) -> A2AServerConfig | None: + """Get A2AServerConfig from an agent's a2a configuration. + + Args: + agent: The Agent instance to check. + + Returns: + A2AServerConfig if present, None otherwise. + """ + if agent.a2a is None: + return None + if isinstance(agent.a2a, A2AServerConfig): + return agent.a2a + if isinstance(agent.a2a, list): + for config in agent.a2a: + if isinstance(config, A2AServerConfig): + return config + return None + + def fetch_agent_card( endpoint: str, auth: AuthScheme | None = None, @@ -298,6 +319,8 @@ def _crew_to_agent_card(crew: Crew, url: str) -> AgentCard: def _agent_to_agent_card(agent: Agent, url: str) -> AgentCard: """Generate an A2A AgentCard from an Agent instance. + Uses A2AServerConfig values when available, falling back to agent properties. + Args: agent: The Agent instance to generate a card for. url: The base URL where this agent will be exposed. @@ -305,40 +328,53 @@ def _agent_to_agent_card(agent: Agent, url: str) -> AgentCard: Returns: AgentCard describing the agent's capabilities. """ + server_config = _get_server_config(agent) or A2AServerConfig() + + name = server_config.name or agent.role + description_parts = [agent.goal] if agent.backstory: description_parts.append(agent.backstory) + description = server_config.description or " ".join(description_parts) - skills: list[AgentSkill] = [] - - if agent.tools: - for tool in agent.tools: - tool_name = getattr(tool, "name", None) or tool.__class__.__name__ - tool_desc = getattr(tool, "description", None) or f"Tool: {tool_name}" - skills.append(_tool_to_skill(tool_name, tool_desc)) + skills: list[AgentSkill] = ( + server_config.skills.copy() if server_config.skills else [] + ) if not skills: - skills.append( - AgentSkill( - id=agent.role.lower().replace(" ", "_"), - name=agent.role, - description=agent.goal, - tags=[agent.role.lower().replace(" ", "-")], + if agent.tools: + for tool in agent.tools: + tool_name = getattr(tool, "name", None) or tool.__class__.__name__ + tool_desc = getattr(tool, "description", None) or f"Tool: {tool_name}" + skills.append(_tool_to_skill(tool_name, tool_desc)) + + if not skills: + skills.append( + AgentSkill( + id=agent.role.lower().replace(" ", "_"), + name=agent.role, + description=agent.goal, + tags=[agent.role.lower().replace(" ", "-")], + ) ) - ) return AgentCard( - name=agent.role, - description=" ".join(description_parts), + name=name, + description=description, url=url, - version="1.0.0", - capabilities=AgentCapabilities( - streaming=True, - push_notifications=True, - ), - default_input_modes=["text/plain", "application/json"], - default_output_modes=["text/plain", "application/json"], + version=server_config.version, + capabilities=server_config.capabilities, + default_input_modes=server_config.default_input_modes, + default_output_modes=server_config.default_output_modes, skills=skills, + protocol_version=server_config.protocol_version, + provider=server_config.provider, + documentation_url=server_config.documentation_url, + icon_url=server_config.icon_url, + additional_interfaces=server_config.additional_interfaces, + security=server_config.security, + security_schemes=server_config.security_schemes, + supports_authenticated_extended_card=server_config.supports_authenticated_extended_card, ) @@ -348,9 +384,14 @@ def inject_a2a_server_methods(target: Crew | Agent) -> None: Adds a `to_agent_card(url: str) -> AgentCard` method to the target instance that generates an A2A-compliant AgentCard. + For Agents, this only injects methods if an A2AServerConfig is present. + For Crews, methods are always injected. + Args: target: The Crew or Agent instance to inject methods onto. """ + if not isinstance(target, Crew) and _get_server_config(target) is None: + return def _to_agent_card(self: Crew | Agent, url: str) -> AgentCard: if isinstance(self, Crew): From 422b35cdb784c3b60d06ad605317ab84c7d53235 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 14 Jan 2026 05:11:24 -0500 Subject: [PATCH 30/34] feat: add additional a2a fields, deprecate old config --- lib/crewai/src/crewai/a2a/config.py | 22 +++++++++++++++---- lib/crewai/src/crewai/a2a/utils/agent_card.py | 3 ++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/config.py b/lib/crewai/src/crewai/a2a/config.py index b0ac834054..2b1366e81e 100644 --- a/lib/crewai/src/crewai/a2a/config.py +++ b/lib/crewai/src/crewai/a2a/config.py @@ -10,6 +10,7 @@ from a2a.types import ( AgentCapabilities, + AgentCardSignature, AgentInterface, AgentProvider, AgentSkill, @@ -34,7 +35,10 @@ def _get_default_update_config() -> UpdateConfig: return StreamingConfig() -@deprecated("Use A2AClientConfig instead.") +@deprecated( + "Use `crewai.a2a.config.A2AClientConfig` or `crewai.a2a.config.A2AServerConfig` instead.", + category=FutureWarning, +) class A2AConfig(BaseModel): """Configuration for A2A protocol integration. @@ -167,6 +171,8 @@ class A2AServerConfig(BaseModel): security: Security requirement objects for all interactions. security_schemes: Security schemes available to authorize requests. supports_authenticated_extended_card: Whether agent provides extended card to authenticated users. + url: Preferred endpoint URL for the agent. + signatures: JSON Web Signatures for the AgentCard. """ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -198,7 +204,7 @@ class A2AServerConfig(BaseModel): capabilities: AgentCapabilities = Field( default_factory=lambda: AgentCapabilities( streaming=True, - push_notifications=True, + push_notifications=False, ), description="Declaration of optional capabilities supported by the agent", ) @@ -214,11 +220,11 @@ class A2AServerConfig(BaseModel): default=None, description="Information about the agent's service provider", ) - documentation_url: str | None = Field( + documentation_url: Url | None = Field( default=None, description="URL to the agent's documentation", ) - icon_url: str | None = Field( + icon_url: Url | None = Field( default=None, description="URL to an icon for the agent", ) @@ -238,3 +244,11 @@ class A2AServerConfig(BaseModel): default=False, description="Whether agent provides extended card to authenticated users", ) + url: Url | None = Field( + default=None, + description="Preferred endpoint URL for the agent. Set at runtime if not provided.", + ) + signatures: list[AgentCardSignature] = Field( + default_factory=list, + description="JSON Web Signatures for the AgentCard", + ) diff --git a/lib/crewai/src/crewai/a2a/utils/agent_card.py b/lib/crewai/src/crewai/a2a/utils/agent_card.py index bd8c4db659..513274ede0 100644 --- a/lib/crewai/src/crewai/a2a/utils/agent_card.py +++ b/lib/crewai/src/crewai/a2a/utils/agent_card.py @@ -361,7 +361,7 @@ def _agent_to_agent_card(agent: Agent, url: str) -> AgentCard: return AgentCard( name=name, description=description, - url=url, + url=server_config.url or url, version=server_config.version, capabilities=server_config.capabilities, default_input_modes=server_config.default_input_modes, @@ -375,6 +375,7 @@ def _agent_to_agent_card(agent: Agent, url: str) -> AgentCard: security=server_config.security, security_schemes=server_config.security_schemes, supports_authenticated_extended_card=server_config.supports_authenticated_extended_card, + signatures=server_config.signatures, ) From 68df061c203fe1c55b2f2cb3ad9bbd7ecce6036b Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 14 Jan 2026 05:46:35 -0500 Subject: [PATCH 31/34] feat: inject server methods --- lib/crewai/src/crewai/a2a/config.py | 5 ++- lib/crewai/src/crewai/a2a/types.py | 20 +++++++++- .../src/crewai/a2a/utils/response_model.py | 36 +++++++++++------- lib/crewai/src/crewai/a2a/wrapper.py | 38 +++++++++++-------- lib/crewai/src/crewai/agent/core.py | 27 ++++++++----- pyproject.toml | 2 +- 6 files changed, 87 insertions(+), 41 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/config.py b/lib/crewai/src/crewai/a2a/config.py index c93b595baf..1597ae8217 100644 --- a/lib/crewai/src/crewai/a2a/config.py +++ b/lib/crewai/src/crewai/a2a/config.py @@ -36,7 +36,10 @@ def _get_default_update_config() -> UpdateConfig: @deprecated( - "Use `crewai.a2a.config.A2AClientConfig` or `crewai.a2a.config.A2AServerConfig` instead.", + """ + `crewai.a2a.config.A2AConfig` is deprecated and will be removed in v2.0.0, + use `crewai.a2a.config.A2AClientConfig` or `crewai.a2a.config.A2AServerConfig` instead. + """, category=FutureWarning, ) class A2AConfig(BaseModel): diff --git a/lib/crewai/src/crewai/a2a/types.py b/lib/crewai/src/crewai/a2a/types.py index 2cd318e429..b26c2ab56f 100644 --- a/lib/crewai/src/crewai/a2a/types.py +++ b/lib/crewai/src/crewai/a2a/types.py @@ -1,6 +1,17 @@ """Type definitions for A2A protocol message parts.""" -from typing import Annotated, Any, Literal, Protocol, TypedDict, runtime_checkable +from __future__ import annotations + +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Literal, + Protocol, + TypeAlias, + TypedDict, + runtime_checkable, +) from pydantic import BeforeValidator, HttpUrl, TypeAdapter from typing_extensions import NotRequired @@ -16,6 +27,10 @@ ) +if TYPE_CHECKING: + from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig + + TransportType = Literal["JSONRPC", "GRPC", "HTTP+JSON"] http_url_adapter: TypeAdapter[HttpUrl] = TypeAdapter(HttpUrl) @@ -72,3 +87,6 @@ class PartsDict(TypedDict): StreamingConfig: StreamingHandler, PushNotificationConfig: PushNotificationHandler, } + +A2AConfigTypes: TypeAlias = A2AConfig | A2AServerConfig | A2AClientConfig +A2AClientConfigTypes: TypeAlias = A2AConfig | A2AClientConfig diff --git a/lib/crewai/src/crewai/a2a/utils/response_model.py b/lib/crewai/src/crewai/a2a/utils/response_model.py index 9da0f35ea5..2f19a71ae6 100644 --- a/lib/crewai/src/crewai/a2a/utils/response_model.py +++ b/lib/crewai/src/crewai/a2a/utils/response_model.py @@ -4,7 +4,8 @@ from pydantic import BaseModel, Field, create_model -from crewai.a2a.config import A2AConfig +from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig +from crewai.a2a.types import A2AClientConfigTypes, A2AConfigTypes from crewai.types.utils import create_literals_from_strings @@ -46,36 +47,45 @@ def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel]: def extract_a2a_agent_ids_from_config( - a2a_config: list[A2AConfig] | A2AConfig | None, -) -> tuple[list[A2AConfig], tuple[str, ...]]: + a2a_config: list[A2AConfigTypes] | A2AConfigTypes | None, +) -> tuple[list[A2AClientConfigTypes], tuple[str, ...]]: """Extract A2A agent IDs from A2A configuration. + Filters out A2AServerConfig since it doesn't have an endpoint for delegation. + Args: - a2a_config: A2A configuration. + a2a_config: A2A configuration (any type). Returns: - Tuple of A2A configs list and agent endpoint IDs. + Tuple of client A2A configs list and agent endpoint IDs. """ if a2a_config is None: return [], () - if isinstance(a2a_config, A2AConfig): - a2a_agents = [a2a_config] + configs: list[A2AConfigTypes] + if isinstance(a2a_config, (A2AConfig, A2AClientConfig, A2AServerConfig)): + configs = [a2a_config] else: - a2a_agents = a2a_config - return a2a_agents, tuple(config.endpoint for config in a2a_agents) + configs = a2a_config + + # Filter to only client configs (those with endpoint) + client_configs: list[A2AClientConfigTypes] = [ + config for config in configs if isinstance(config, (A2AConfig, A2AClientConfig)) + ] + + return client_configs, tuple(config.endpoint for config in client_configs) def get_a2a_agents_and_response_model( - a2a_config: list[A2AConfig] | A2AConfig | None, -) -> tuple[list[A2AConfig], type[BaseModel]]: + a2a_config: list[A2AConfigTypes] | A2AConfigTypes | None, +) -> tuple[list[A2AClientConfigTypes], type[BaseModel]]: """Get A2A agent configs and response model. Args: - a2a_config: A2A configuration. + a2a_config: A2A configuration (any type). Returns: - Tuple of A2A configs and response model. + Tuple of client A2A configs and response model. """ a2a_agents, agent_ids = extract_a2a_agent_ids_from_config(a2a_config=a2a_config) diff --git a/lib/crewai/src/crewai/a2a/wrapper.py b/lib/crewai/src/crewai/a2a/wrapper.py index b5661e4b66..37a6c665e9 100644 --- a/lib/crewai/src/crewai/a2a/wrapper.py +++ b/lib/crewai/src/crewai/a2a/wrapper.py @@ -15,7 +15,7 @@ from a2a.types import Role, TaskState from pydantic import BaseModel, ValidationError -from crewai.a2a.config import A2AConfig +from crewai.a2a.config import A2AClientConfig, A2AConfig from crewai.a2a.extensions.base import ExtensionRegistry from crewai.a2a.task_helpers import TaskStateResult from crewai.a2a.templates import ( @@ -26,7 +26,11 @@ UNAVAILABLE_AGENTS_NOTICE_TEMPLATE, ) from crewai.a2a.types import AgentResponseProtocol -from crewai.a2a.utils.agent_card import afetch_agent_card, fetch_agent_card +from crewai.a2a.utils.agent_card import ( + afetch_agent_card, + fetch_agent_card, + inject_a2a_server_methods, +) from crewai.a2a.utils.delegation import ( aexecute_a2a_delegation, execute_a2a_delegation, @@ -121,10 +125,12 @@ async def aexecute_task_with_a2a( agent, "aexecute_task", MethodType(aexecute_task_with_a2a, agent) ) + inject_a2a_server_methods(agent) + def _fetch_card_from_config( - config: A2AConfig, -) -> tuple[A2AConfig, AgentCard | Exception]: + config: A2AConfig | A2AClientConfig, +) -> tuple[A2AConfig | A2AClientConfig, AgentCard | Exception]: """Fetch agent card from A2A config. Args: @@ -145,7 +151,7 @@ def _fetch_card_from_config( def _fetch_agent_cards_concurrently( - a2a_agents: list[A2AConfig], + a2a_agents: list[A2AConfig | A2AClientConfig], ) -> tuple[dict[str, AgentCard], dict[str, str]]: """Fetch agent cards concurrently for multiple A2A agents. @@ -180,7 +186,7 @@ def _fetch_agent_cards_concurrently( def _execute_task_with_a2a( self: Agent, - a2a_agents: list[A2AConfig], + a2a_agents: list[A2AConfig | A2AClientConfig], original_fn: Callable[..., str], task: Task, agent_response_model: type[BaseModel], @@ -269,7 +275,7 @@ def _execute_task_with_a2a( def _augment_prompt_with_a2a( - a2a_agents: list[A2AConfig], + a2a_agents: list[A2AConfig | A2AClientConfig], task_description: str, agent_cards: dict[str, AgentCard], conversation_history: list[Message] | None = None, @@ -522,11 +528,11 @@ def _prepare_delegation_context( task: Task, original_task_description: str | None, ) -> tuple[ - list[A2AConfig], + list[A2AConfig | A2AClientConfig], type[BaseModel], str, str, - A2AConfig, + A2AConfig | A2AClientConfig, str | None, str | None, dict[str, Any] | None, @@ -590,7 +596,7 @@ def _handle_task_completion( task: Task, task_id_config: str | None, reference_task_ids: list[str], - agent_config: A2AConfig, + agent_config: A2AConfig | A2AClientConfig, turn_num: int, ) -> tuple[str | None, str | None, list[str]]: """Handle task completion state including reference task updates. @@ -630,7 +636,7 @@ def _handle_agent_response_and_continue( a2a_result: TaskStateResult, agent_id: str, agent_cards: dict[str, AgentCard] | None, - a2a_agents: list[A2AConfig], + a2a_agents: list[A2AConfig | A2AClientConfig], original_task_description: str, conversation_history: list[Message], turn_num: int, @@ -867,8 +873,8 @@ def _delegate_to_a2a( async def _afetch_card_from_config( - config: A2AConfig, -) -> tuple[A2AConfig, AgentCard | Exception]: + config: A2AConfig | A2AClientConfig, +) -> tuple[A2AConfig | A2AClientConfig, AgentCard | Exception]: """Fetch agent card from A2A config asynchronously.""" try: card = await afetch_agent_card( @@ -882,7 +888,7 @@ async def _afetch_card_from_config( async def _afetch_agent_cards_concurrently( - a2a_agents: list[A2AConfig], + a2a_agents: list[A2AConfig | A2AClientConfig], ) -> tuple[dict[str, AgentCard], dict[str, str]]: """Fetch agent cards concurrently for multiple A2A agents using asyncio.""" agent_cards: dict[str, AgentCard] = {} @@ -907,7 +913,7 @@ async def _afetch_agent_cards_concurrently( async def _aexecute_task_with_a2a( self: Agent, - a2a_agents: list[A2AConfig], + a2a_agents: list[A2AConfig | A2AClientConfig], original_fn: Callable[..., Coroutine[Any, Any, str]], task: Task, agent_response_model: type[BaseModel], @@ -986,7 +992,7 @@ async def _ahandle_agent_response_and_continue( a2a_result: TaskStateResult, agent_id: str, agent_cards: dict[str, AgentCard] | None, - a2a_agents: list[A2AConfig], + a2a_agents: list[A2AConfig | A2AClientConfig], original_task_description: str, conversation_history: list[Message], turn_num: int, diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index d06b3b6f75..1c7a653ece 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -17,7 +17,7 @@ from pydantic import BaseModel, Field, InstanceOf, PrivateAttr, model_validator from typing_extensions import Self -from crewai.a2a.config import A2AConfig +from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig from crewai.agent.utils import ( ahandle_knowledge_retrieval, apply_training_data, @@ -73,7 +73,7 @@ from crewai.utilities.converter import Converter from crewai.utilities.guardrail_types import GuardrailType from crewai.utilities.llm_utils import create_llm -from crewai.utilities.prompts import Prompts +from crewai.utilities.prompts import Prompts, StandardPromptResult, SystemPromptResult from crewai.utilities.token_counter_callback import TokenCalcHandler from crewai.utilities.training_handler import CrewTrainingHandler @@ -218,9 +218,18 @@ class Agent(BaseAgent): guardrail_max_retries: int = Field( default=3, description="Maximum number of retries when guardrail fails" ) - a2a: list[A2AConfig] | A2AConfig | None = Field( + a2a: ( + list[A2AConfig | A2AServerConfig | A2AClientConfig] + | A2AConfig + | A2AServerConfig + | A2AClientConfig + | None + ) = Field( default=None, - description="A2A (Agent-to-Agent) configuration for delegating tasks to remote agents. Can be a single A2AConfig or a dict mapping agent IDs to configs.", + description=""" + A2A (Agent-to-Agent) configuration for delegating tasks to remote agents. + Can be a single A2AConfig/A2AClientConfig/A2AServerConfig, or a list of any number of A2AConfig/A2AClientConfig with a single A2AServerConfig. + """, ) executor_class: type[CrewAgentExecutor] | type[CrewAgentExecutorFlow] = Field( default=CrewAgentExecutor, @@ -733,7 +742,7 @@ def create_agent_executor( if self.agent_executor is not None: self._update_executor_parameters( task=task, - tools=parsed_tools, + tools=parsed_tools, # type: ignore[arg-type] raw_tools=raw_tools, prompt=prompt, stop_words=stop_words, @@ -742,7 +751,7 @@ def create_agent_executor( else: self.agent_executor = self.executor_class( llm=cast(BaseLLM, self.llm), - task=task, + task=task, # type: ignore[arg-type] i18n=self.i18n, agent=self, crew=self.crew, @@ -765,11 +774,11 @@ def create_agent_executor( def _update_executor_parameters( self, task: Task | None, - tools: list, + tools: list[BaseTool], raw_tools: list[BaseTool], - prompt: dict, + prompt: SystemPromptResult | StandardPromptResult, stop_words: list[str], - rpm_limit_fn: Callable | None, + rpm_limit_fn: Callable | None, # type: ignore[type-arg] ) -> None: """Update executor parameters without recreating instance. diff --git a/pyproject.toml b/pyproject.toml index 594bdf9aac..de3f03ecb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,7 @@ show_error_codes = true warn_unused_ignores = true python_version = "3.12" exclude = "(?x)(^lib/crewai/src/crewai/cli/templates/ | ^lib/crewai/tests/ | ^lib/crewai-tools/tests/)" -plugins = ["pydantic.mypy", "crewai.mypy"] +plugins = ["pydantic.mypy"] [tool.bandit] From 372ba9a0d91c6a84fb330b5d52ef93c914bbd988 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 14 Jan 2026 05:53:04 -0500 Subject: [PATCH 32/34] fix: reorganize type declarations --- lib/crewai/src/crewai/a2a/types.py | 9 --------- lib/crewai/src/crewai/a2a/utils/response_model.py | 7 ++++++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/types.py b/lib/crewai/src/crewai/a2a/types.py index b26c2ab56f..90473b669c 100644 --- a/lib/crewai/src/crewai/a2a/types.py +++ b/lib/crewai/src/crewai/a2a/types.py @@ -3,12 +3,10 @@ from __future__ import annotations from typing import ( - TYPE_CHECKING, Annotated, Any, Literal, Protocol, - TypeAlias, TypedDict, runtime_checkable, ) @@ -27,10 +25,6 @@ ) -if TYPE_CHECKING: - from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig - - TransportType = Literal["JSONRPC", "GRPC", "HTTP+JSON"] http_url_adapter: TypeAdapter[HttpUrl] = TypeAdapter(HttpUrl) @@ -87,6 +81,3 @@ class PartsDict(TypedDict): StreamingConfig: StreamingHandler, PushNotificationConfig: PushNotificationHandler, } - -A2AConfigTypes: TypeAlias = A2AConfig | A2AServerConfig | A2AClientConfig -A2AClientConfigTypes: TypeAlias = A2AConfig | A2AClientConfig diff --git a/lib/crewai/src/crewai/a2a/utils/response_model.py b/lib/crewai/src/crewai/a2a/utils/response_model.py index 2f19a71ae6..44d8a5ba64 100644 --- a/lib/crewai/src/crewai/a2a/utils/response_model.py +++ b/lib/crewai/src/crewai/a2a/utils/response_model.py @@ -2,13 +2,18 @@ from __future__ import annotations +from typing import TypeAlias + from pydantic import BaseModel, Field, create_model from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig -from crewai.a2a.types import A2AClientConfigTypes, A2AConfigTypes from crewai.types.utils import create_literals_from_strings +A2AConfigTypes: TypeAlias = A2AConfig | A2AServerConfig | A2AClientConfig +A2AClientConfigTypes: TypeAlias = A2AConfig | A2AClientConfig + + def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel]: """Create a dynamic AgentResponse model with Literal types for agent IDs. From 34b6137104021b208c5068c0b4dfbdad9b0f0763 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 14 Jan 2026 06:13:36 -0500 Subject: [PATCH 33/34] chore: add agent card structure tests --- lib/crewai/src/crewai/a2a/utils/agent_card.py | 2 +- lib/crewai/tests/a2a/utils/test_agent_card.py | 325 ++++++++++++++++++ 2 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 lib/crewai/tests/a2a/utils/test_agent_card.py diff --git a/lib/crewai/src/crewai/a2a/utils/agent_card.py b/lib/crewai/src/crewai/a2a/utils/agent_card.py index 513274ede0..3349d8d8c7 100644 --- a/lib/crewai/src/crewai/a2a/utils/agent_card.py +++ b/lib/crewai/src/crewai/a2a/utils/agent_card.py @@ -399,4 +399,4 @@ def _to_agent_card(self: Crew | Agent, url: str) -> AgentCard: return _crew_to_agent_card(self, url) return _agent_to_agent_card(self, url) - target.to_agent_card = MethodType(_to_agent_card, target) # type: ignore[union-attr] + object.__setattr__(target, "to_agent_card", MethodType(_to_agent_card, target)) diff --git a/lib/crewai/tests/a2a/utils/test_agent_card.py b/lib/crewai/tests/a2a/utils/test_agent_card.py new file mode 100644 index 0000000000..fb96710a79 --- /dev/null +++ b/lib/crewai/tests/a2a/utils/test_agent_card.py @@ -0,0 +1,325 @@ +"""Tests for A2A agent card utilities.""" + +from __future__ import annotations + +from a2a.types import AgentCard, AgentSkill + +from crewai import Agent +from crewai.a2a.config import A2AClientConfig, A2AServerConfig +from crewai.a2a.utils.agent_card import inject_a2a_server_methods + + +class TestInjectA2AServerMethods: + """Tests for inject_a2a_server_methods function.""" + + def test_agent_with_server_config_gets_to_agent_card_method(self) -> None: + """Agent with A2AServerConfig should have to_agent_card method injected.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + assert hasattr(agent, "to_agent_card") + assert callable(agent.to_agent_card) + + def test_agent_without_server_config_no_injection(self) -> None: + """Agent without A2AServerConfig should not get to_agent_card method.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AClientConfig(endpoint="http://example.com"), + ) + + assert not hasattr(agent, "to_agent_card") + + def test_agent_without_a2a_no_injection(self) -> None: + """Agent without any a2a config should not get to_agent_card method.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + ) + + assert not hasattr(agent, "to_agent_card") + + def test_agent_with_mixed_configs_gets_injection(self) -> None: + """Agent with list containing A2AServerConfig should get to_agent_card.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=[ + A2AClientConfig(endpoint="http://example.com"), + A2AServerConfig(name="My Agent"), + ], + ) + + assert hasattr(agent, "to_agent_card") + assert callable(agent.to_agent_card) + + def test_manual_injection_on_plain_agent(self) -> None: + """inject_a2a_server_methods should work when called manually.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + ) + # Manually set server config and inject + object.__setattr__(agent, "a2a", A2AServerConfig()) + inject_a2a_server_methods(agent) + + assert hasattr(agent, "to_agent_card") + assert callable(agent.to_agent_card) + + +class TestToAgentCard: + """Tests for the injected to_agent_card method.""" + + def test_returns_agent_card(self) -> None: + """to_agent_card should return an AgentCard instance.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert isinstance(card, AgentCard) + + def test_uses_agent_role_as_name(self) -> None: + """AgentCard name should default to agent role.""" + agent = Agent( + role="Data Analyst", + goal="Analyze data", + backstory="Expert analyst", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert card.name == "Data Analyst" + + def test_uses_server_config_name(self) -> None: + """AgentCard name should prefer A2AServerConfig.name over role.""" + agent = Agent( + role="Data Analyst", + goal="Analyze data", + backstory="Expert analyst", + a2a=A2AServerConfig(name="Custom Agent Name"), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert card.name == "Custom Agent Name" + + def test_uses_goal_as_description(self) -> None: + """AgentCard description should include agent goal.""" + agent = Agent( + role="Test Agent", + goal="Accomplish important tasks", + backstory="Has extensive experience", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert "Accomplish important tasks" in card.description + + def test_uses_server_config_description(self) -> None: + """AgentCard description should prefer A2AServerConfig.description.""" + agent = Agent( + role="Test Agent", + goal="Accomplish important tasks", + backstory="Has extensive experience", + a2a=A2AServerConfig(description="Custom description"), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert card.description == "Custom description" + + def test_uses_provided_url(self) -> None: + """AgentCard url should use the provided URL.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://my-server.com:9000") + + assert card.url == "http://my-server.com:9000" + + def test_uses_server_config_url(self) -> None: + """AgentCard url should prefer A2AServerConfig.url over provided URL.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(url="http://configured-url.com"), + ) + + card = agent.to_agent_card("http://fallback-url.com") + + assert card.url == "http://configured-url.com/" + + def test_generates_default_skill(self) -> None: + """AgentCard should have at least one skill based on agent role.""" + agent = Agent( + role="Research Assistant", + goal="Help with research", + backstory="Skilled researcher", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert len(card.skills) >= 1 + skill = card.skills[0] + assert skill.name == "Research Assistant" + assert skill.description == "Help with research" + + def test_uses_server_config_skills(self) -> None: + """AgentCard skills should prefer A2AServerConfig.skills.""" + custom_skill = AgentSkill( + id="custom-skill", + name="Custom Skill", + description="A custom skill", + tags=["custom"], + ) + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(skills=[custom_skill]), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert len(card.skills) == 1 + assert card.skills[0].id == "custom-skill" + assert card.skills[0].name == "Custom Skill" + + def test_includes_custom_version(self) -> None: + """AgentCard should include version from A2AServerConfig.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(version="2.0.0"), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert card.version == "2.0.0" + + def test_default_version(self) -> None: + """AgentCard should have default version 1.0.0.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + + assert card.version == "1.0.0" + + +class TestAgentCardJsonStructure: + """Tests for the JSON structure of AgentCard.""" + + def test_json_has_required_fields(self) -> None: + """AgentCard JSON should contain all required A2A protocol fields.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + json_data = card.model_dump() + + assert "name" in json_data + assert "description" in json_data + assert "url" in json_data + assert "version" in json_data + assert "skills" in json_data + assert "capabilities" in json_data + assert "defaultInputModes" in json_data + assert "defaultOutputModes" in json_data + + def test_json_skills_structure(self) -> None: + """Each skill in JSON should have required fields.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + json_data = card.model_dump() + + assert len(json_data["skills"]) >= 1 + skill = json_data["skills"][0] + assert "id" in skill + assert "name" in skill + assert "description" in skill + assert "tags" in skill + + def test_json_capabilities_structure(self) -> None: + """Capabilities in JSON should have expected fields.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + json_data = card.model_dump() + + capabilities = json_data["capabilities"] + assert "streaming" in capabilities + assert "pushNotifications" in capabilities + + def test_json_serializable(self) -> None: + """AgentCard should be JSON serializable.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + json_str = card.model_dump_json() + + assert isinstance(json_str, str) + assert "Test Agent" in json_str + assert "http://localhost:8000" in json_str + + def test_json_excludes_none_values(self) -> None: + """AgentCard JSON with exclude_none should omit None fields.""" + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + a2a=A2AServerConfig(), + ) + + card = agent.to_agent_card("http://localhost:8000") + json_data = card.model_dump(exclude_none=True) + + assert "provider" not in json_data + assert "documentationUrl" not in json_data + assert "iconUrl" not in json_data From f059dfa139f35aec1dc4593ffa98d8e1e207fc22 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 14 Jan 2026 06:27:13 -0500 Subject: [PATCH 34/34] fix: only inject method on agent --- lib/crewai/src/crewai/a2a/utils/agent_card.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/crewai/src/crewai/a2a/utils/agent_card.py b/lib/crewai/src/crewai/a2a/utils/agent_card.py index 3349d8d8c7..7c798dc1aa 100644 --- a/lib/crewai/src/crewai/a2a/utils/agent_card.py +++ b/lib/crewai/src/crewai/a2a/utils/agent_card.py @@ -379,24 +379,21 @@ def _agent_to_agent_card(agent: Agent, url: str) -> AgentCard: ) -def inject_a2a_server_methods(target: Crew | Agent) -> None: - """Inject A2A server methods onto a Crew or Agent instance. +def inject_a2a_server_methods(agent: Agent) -> None: + """Inject A2A server methods onto an Agent instance. - Adds a `to_agent_card(url: str) -> AgentCard` method to the target - instance that generates an A2A-compliant AgentCard. + Adds a `to_agent_card(url: str) -> AgentCard` method to the agent + that generates an A2A-compliant AgentCard. - For Agents, this only injects methods if an A2AServerConfig is present. - For Crews, methods are always injected. + Only injects if the agent has an A2AServerConfig. Args: - target: The Crew or Agent instance to inject methods onto. + agent: The Agent instance to inject methods onto. """ - if not isinstance(target, Crew) and _get_server_config(target) is None: + if _get_server_config(agent) is None: return - def _to_agent_card(self: Crew | Agent, url: str) -> AgentCard: - if isinstance(self, Crew): - return _crew_to_agent_card(self, url) + def _to_agent_card(self: Agent, url: str) -> AgentCard: return _agent_to_agent_card(self, url) - object.__setattr__(target, "to_agent_card", MethodType(_to_agent_card, target)) + object.__setattr__(agent, "to_agent_card", MethodType(_to_agent_card, agent))