mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-01-21 13:58:15 +00:00
514 lines
16 KiB
Python
514 lines
16 KiB
Python
"""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.a2a.config import A2AServerConfig
|
|
from crewai.crew import Crew
|
|
from crewai.events.event_bus import crewai_event_bus
|
|
from crewai.events.types.a2a_events import (
|
|
A2AAgentCardFetchedEvent,
|
|
A2AAuthenticationFailedEvent,
|
|
A2AConnectionErrorEvent,
|
|
)
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from crewai.a2a.auth.schemas import AuthScheme
|
|
from crewai.agent import Agent
|
|
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,
|
|
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."""
|
|
start_time = time.perf_counter()
|
|
|
|
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()
|
|
|
|
agent_card = AgentCard.model_validate(response.json())
|
|
fetch_time_ms = (time.perf_counter() - start_time) * 1000
|
|
agent_card_dict = agent_card.model_dump(exclude_none=True)
|
|
|
|
crewai_event_bus.emit(
|
|
None,
|
|
A2AAgentCardFetchedEvent(
|
|
endpoint=endpoint,
|
|
a2a_agent_name=agent_card.name,
|
|
agent_card=agent_card_dict,
|
|
protocol_version=agent_card.protocol_version,
|
|
provider=agent_card_dict.get("provider"),
|
|
cached=False,
|
|
fetch_time_ms=fetch_time_ms,
|
|
),
|
|
)
|
|
|
|
return agent_card
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
response_body = e.response.text[:1000] if e.response.text else None
|
|
|
|
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)
|
|
|
|
auth_type = type(auth).__name__ if auth else None
|
|
crewai_event_bus.emit(
|
|
None,
|
|
A2AAuthenticationFailedEvent(
|
|
endpoint=endpoint,
|
|
auth_type=auth_type,
|
|
error=msg,
|
|
status_code=401,
|
|
metadata={
|
|
"elapsed_ms": elapsed_ms,
|
|
"response_body": response_body,
|
|
"www_authenticate": www_auth,
|
|
"request_url": str(e.request.url),
|
|
},
|
|
),
|
|
)
|
|
|
|
raise A2AClientHTTPError(401, msg) from e
|
|
|
|
crewai_event_bus.emit(
|
|
None,
|
|
A2AConnectionErrorEvent(
|
|
endpoint=endpoint,
|
|
error=str(e),
|
|
error_type="http_error",
|
|
status_code=e.response.status_code,
|
|
operation="fetch_agent_card",
|
|
metadata={
|
|
"elapsed_ms": elapsed_ms,
|
|
"response_body": response_body,
|
|
"request_url": str(e.request.url),
|
|
},
|
|
),
|
|
)
|
|
raise
|
|
|
|
except httpx.TimeoutException as e:
|
|
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
crewai_event_bus.emit(
|
|
None,
|
|
A2AConnectionErrorEvent(
|
|
endpoint=endpoint,
|
|
error=str(e),
|
|
error_type="timeout",
|
|
operation="fetch_agent_card",
|
|
metadata={
|
|
"elapsed_ms": elapsed_ms,
|
|
"timeout_config": timeout,
|
|
"request_url": str(e.request.url) if e.request else None,
|
|
},
|
|
),
|
|
)
|
|
raise
|
|
|
|
except httpx.ConnectError as e:
|
|
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
crewai_event_bus.emit(
|
|
None,
|
|
A2AConnectionErrorEvent(
|
|
endpoint=endpoint,
|
|
error=str(e),
|
|
error_type="connection_error",
|
|
operation="fetch_agent_card",
|
|
metadata={
|
|
"elapsed_ms": elapsed_ms,
|
|
"request_url": str(e.request.url) if e.request else None,
|
|
},
|
|
),
|
|
)
|
|
raise
|
|
|
|
except httpx.RequestError as e:
|
|
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
crewai_event_bus.emit(
|
|
None,
|
|
A2AConnectionErrorEvent(
|
|
endpoint=endpoint,
|
|
error=str(e),
|
|
error_type="request_error",
|
|
operation="fetch_agent_card",
|
|
metadata={
|
|
"elapsed_ms": elapsed_ms,
|
|
"request_url": str(e.request.url) if e.request else None,
|
|
},
|
|
),
|
|
)
|
|
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.
|
|
|
|
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.
|
|
|
|
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] = (
|
|
server_config.skills.copy() if server_config.skills else []
|
|
)
|
|
|
|
if not skills:
|
|
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=name,
|
|
description=description,
|
|
url=server_config.url or url,
|
|
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,
|
|
signatures=server_config.signatures,
|
|
)
|
|
|
|
|
|
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 agent
|
|
that generates an A2A-compliant AgentCard.
|
|
|
|
Only injects if the agent has an A2AServerConfig.
|
|
|
|
Args:
|
|
agent: The Agent instance to inject methods onto.
|
|
"""
|
|
if _get_server_config(agent) is None:
|
|
return
|
|
|
|
def _to_agent_card(self: Agent, url: str) -> AgentCard:
|
|
return _agent_to_agent_card(self, url)
|
|
|
|
object.__setattr__(agent, "to_agent_card", MethodType(_to_agent_card, agent))
|