diff --git a/docs/devel_doc/openapi.json b/docs/devel_doc/openapi.json
index ba4df6865..7a7e488aa 100644
--- a/docs/devel_doc/openapi.json
+++ b/docs/devel_doc/openapi.json
@@ -10874,6 +10874,74 @@
},
"components": {
"schemas": {
+ "A2AAgentEndpointConfiguration": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Agent name",
+ "description": "Unique identifier for this agent, used by the delegation tool."
+ },
+ "url": {
+ "type": "string",
+ "minLength": 1,
+ "format": "uri",
+ "title": "Agent URL",
+ "description": "Base URL of the external A2A agent."
+ },
+ "auth_token": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "password",
+ "writeOnly": true
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Auth token",
+ "description": "Static bearer token for authenticating with this agent."
+ },
+ "timeout": {
+ "type": "integer",
+ "exclusiveMinimum": 0.0,
+ "title": "Request timeout",
+ "description": "Timeout in seconds for requests to this agent. Default is 30 seconds.",
+ "default": 30
+ },
+ "max_retries": {
+ "type": "integer",
+ "minimum": 0.0,
+ "title": "Maximum retries",
+ "description": "Maximum number of retry attempts on transient failures. Set to 0 to disable retries.",
+ "default": 3
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name",
+ "url"
+ ],
+ "title": "A2AAgentEndpointConfiguration",
+ "description": "Configuration for a single external A2A agent endpoint.\n\nAttributes:\n name: Agent identifier used in delegation tool calls.\n url: Base URL of the A2A agent.\n auth_token: Optional static bearer token for authenticating with this agent.\n timeout: Request timeout in seconds for this agent.\n max_retries: Maximum retry attempts on transient failures."
+ },
+ "A2AAgentsConfiguration": {
+ "properties": {
+ "agents": {
+ "items": {
+ "$ref": "#/components/schemas/A2AAgentEndpointConfiguration"
+ },
+ "type": "array",
+ "title": "A2A agent endpoints",
+ "description": "External A2A agents available for task delegation."
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "A2AAgentsConfiguration",
+ "description": "Configuration for external A2A agent connections.\n\nAttributes:\n agents: List of external A2A agent endpoint configurations."
+ },
"A2AStateConfiguration": {
"properties": {
"sqlite": {
@@ -12322,6 +12390,18 @@
"title": "A2A state configuration",
"description": "Configuration for A2A protocol persistent state storage."
},
+ "a2a_agents": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/A2AAgentsConfiguration"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "A2A clients configuration",
+ "description": "External A2A agents available for task delegation."
+ },
"quota_handlers": {
"$ref": "#/components/schemas/QuotaHandlersConfiguration",
"title": "Quota handlers",
diff --git a/docs/user_doc/a2a_protocol.md b/docs/user_doc/a2a_protocol.md
index af6648d8f..15c0876e5 100644
--- a/docs/user_doc/a2a_protocol.md
+++ b/docs/user_doc/a2a_protocol.md
@@ -4,7 +4,10 @@ This document describes the A2A (Agent-to-Agent) protocol implementation in Ligh
## Overview
-The A2A protocol is an open standard for agent-to-agent communication that allows different AI agents to discover, communicate, and collaborate with each other. Lightspeed Core Stack implements the A2A protocol to expose its AI capabilities to other agents and systems.
+The A2A protocol is an open standard for agent-to-agent communication that allows different AI agents to discover, communicate, and collaborate with each other. Lightspeed Core Stack implements both sides of the A2A protocol:
+
+- **A2A Server**: Other agents can call LCS to leverage its AI capabilities, MCP tools, and RAG
+- **A2A Client**: LCS can discover and delegate tasks to external A2A-compliant agents during inference
### Key Concepts
@@ -310,8 +313,8 @@ The `A2AAgentExecutor` class implements the A2A `AgentExecutor` interface:
1. **Receives A2A Request**: Extracts user input from the A2A message
2. **Creates Query Request**: Builds an internal `QueryRequest` with conversation context
-3. **Calls Llama Stack**: Uses the Responses API to get streaming responses
-4. **Converts Events**: Transforms Responses API streaming chunks to A2A events
+3. **Builds Pydantic-AI Agent**: Uses `build_agent()` with the same capabilities as the query/streaming endpoints (skills, A2A delegation)
+4. **Streams via Agent**: Runs `agent.run_stream_events()` and converts pydantic-ai events to A2A events
5. **Manages State**: Tracks task state and publishes status updates
### Event Flow
@@ -786,8 +789,92 @@ protocolVersion: "0.3.0"
The protocol version is included in the agent card response and indicates which version of the A2A protocol specification the agent implements.
+## A2A Client: Delegating to Remote Agents
+
+LCS can also act as an **A2A client**, discovering and delegating tasks to external A2A-compliant agents during inference. This enables multi-agent orchestration where a single LCS instance coordinates work across specialized agents.
+
+### Architecture
+
+```text
+┌──────────────────────────────────────────────────────┐
+│ LCS Orchestrator │
+│ │
+│ ┌──────────────┐ ┌──────────────────────────────┐ │
+│ │ build_agent()├─▶│ A2ADelegationCapability │ │
+│ └──────────────┘ │ - list_agents() │ │
+│ │ - delegate_to_agent(name, │ │
+│ │ task) │ │
+│ └──────────────┬───────────────┘ │
+└──────────────────────────────────┼───────────────────┘
+ │ A2A protocol
+ ┌──────────────┼──────────────┐
+ ▼ ▼ ▼
+ ┌────────────┐ ┌──────────┐ ┌──────────────┐
+ │ Remote │ │ Remote │ │ Remote │
+ │ Agent A │ │ Agent B │ │ Agent ... │
+ │ (any A2A │ │ │ │ │
+ │ server) │ │ │ │ │
+ └────────────┘ └──────────┘ └──────────────┘
+```
+
+### Configuration
+
+Add an `a2a_agents` section to `lightspeed-stack.yaml`:
+
+```yaml
+a2a_agents:
+ agents:
+ - name: "openshift-agent"
+ url: "http://openshift-ls.example.com:8082"
+ timeout: 30 # seconds (default: 30)
+ max_retries: 3 # retry on transient failures (default: 3)
+
+ - name: "ansible-agent"
+ url: "http://ansible-ls.example.com:8081"
+ auth_token: "${ANSIBLE_AGENT_TOKEN}" # optional bearer token
+
+ - name: "rhel-agent"
+ url: "http://rhel-ls.example.com:8083"
+```
+
+#### Configuration Fields
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `name` | string | required | Identifier used by the LLM to select this agent |
+| `url` | string | required | Base URL of the remote A2A agent |
+| `auth_token` | string | none | Static bearer token for authentication |
+| `timeout` | int | 30 | Request timeout in seconds |
+| `max_retries` | int | 3 | Retry attempts on connection failures |
+
+### How It Works
+
+1. **Startup**: LCS connects to each configured agent, fetches its agent card, and caches its capabilities.
+
+2. **Tool registration**: An `A2ADelegationCapability` is added to the pydantic-ai agent (used by query, streaming, and A2A endpoints) with two tools:
+ - `list_agents()` — returns agent names and descriptions
+ - `delegate_to_agent(agent_name, task)` — sends a task to a remote agent via A2A and returns its response
+
+3. **Runtime**: The LLM decides when to delegate based on the agent descriptions injected into the system prompt. It can delegate to one or multiple agents per turn.
+
+4. **Parallel delegation**: If the LLM emits multiple `delegate_to_agent` calls in one response, they run concurrently.
+
+5. **Error handling**: If a remote agent is unavailable or times out, the tool returns an error message to the LLM, which can try another agent or answer directly.
+
+### Authentication
+
+The orchestrator authenticates to remote agents using **service account credentials** (the `auth_token` field), not end-user tokens. Each remote agent validates the token using its own authentication module.
+
+| Scenario | Configuration |
+|----------|---------------|
+| No auth (internal network) | Omit `auth_token` |
+| Static bearer token | Set `auth_token` in YAML |
+| Environment variable | `auth_token: "${MY_AGENT_TOKEN}"` |
+
## References
-- [A2A Protocol Specification](https://github.com/google/A2A)
+- [A2A Protocol Specification](https://a2a-protocol.org/latest/)
+- [a2a-sdk (Python)](https://pypi.org/project/a2a-sdk/)
+- [A2A Samples & Test Suite](https://github.com/a2aproject/a2a-samples)
- [Llama Stack Documentation](https://llama-stack.readthedocs.io/)
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
diff --git a/examples/lightspeed-stack-a2a-agents.yaml b/examples/lightspeed-stack-a2a-agents.yaml
new file mode 100644
index 000000000..4affb234e
--- /dev/null
+++ b/examples/lightspeed-stack-a2a-agents.yaml
@@ -0,0 +1,41 @@
+# Example: A2A remote agent delegation
+#
+# Configures LCS as an orchestrator that can delegate tasks to external
+# A2A-compliant agents. The LLM decides when to delegate based on agent
+# descriptions from their agent cards.
+#
+# See docs/a2a_protocol.md for details.
+
+name: Lightspeed Orchestrator
+service:
+ host: 0.0.0.0
+ port: 8080
+ auth_enabled: false
+llama_stack:
+ url: http://localhost:8321
+ api_key: ${env.LLAMA_STACK_API_KEY}
+user_data_collection:
+ feedback_enabled: false
+authentication:
+ module: "noop"
+inference:
+ default_provider: openai
+ default_model: gpt-4o-mini
+
+# Remote A2A agents available for task delegation.
+# At startup, LCS connects to each agent, fetches its agent card,
+# and registers delegation tools with the pydantic-ai agent.
+a2a_agents:
+ agents:
+ - name: "openshift-agent"
+ url: "http://openshift-ls:8082"
+ timeout: 30
+ max_retries: 3
+
+ - name: "ansible-agent"
+ url: "http://ansible-ls:8081"
+ auth_token: "${env.ANSIBLE_AGENT_TOKEN}"
+ timeout: 60
+
+ - name: "rhel-agent"
+ url: "http://rhel-ls:8083"
diff --git a/src/a2a_client/__init__.py b/src/a2a_client/__init__.py
new file mode 100644
index 000000000..a742de7d8
--- /dev/null
+++ b/src/a2a_client/__init__.py
@@ -0,0 +1,9 @@
+"""A2A client module for discovering and delegating tasks to external agents."""
+
+from a2a_client.capability import A2ADelegationCapability
+from a2a_client.manager import A2AClientManager
+
+__all__ = [
+ "A2AClientManager",
+ "A2ADelegationCapability",
+]
diff --git a/src/a2a_client/capability.py b/src/a2a_client/capability.py
new file mode 100644
index 000000000..55b7c8b99
--- /dev/null
+++ b/src/a2a_client/capability.py
@@ -0,0 +1,182 @@
+"""Pydantic-ai capability for delegating tasks to external A2A agents."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+from a2a.client import A2AClientError
+from a2a.client.helpers import create_text_message_object
+from a2a.types import (
+ Message,
+ Role,
+ TaskArtifactUpdateEvent,
+ TaskState,
+ TextPart,
+)
+from pydantic_ai.capabilities import AbstractCapability
+from pydantic_ai.exceptions import ModelRetry
+from pydantic_ai.toolsets import FunctionToolset
+
+from log import get_logger
+
+logger = get_logger(__name__)
+
+
+def _extract_text_from_artifact(event: TaskArtifactUpdateEvent) -> str:
+ """Extract text content from an A2A artifact event.
+
+ Parameters:
+ event: A2A artifact update event.
+
+ Returns:
+ Concatenated text from all text parts in the artifact.
+ """
+ parts = []
+ for part in event.artifact.parts:
+ if isinstance(part.root, TextPart):
+ parts.append(part.root.text)
+ return "".join(parts)
+
+
+@dataclass
+class A2ADelegationCapability(AbstractCapability[object]):
+ """Capability enabling delegation of tasks to external A2A agents.
+
+ Provides two tools to the pydantic-ai agent:
+ - ``list_agents``: list available agents and their descriptions
+ - ``delegate_to_agent``: send a task to a named agent and return its response
+ """
+
+ _manager: Any = field(repr=False)
+ _toolset: FunctionToolset[object] = field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ """Register delegation tools on the internal toolset."""
+ self._toolset = FunctionToolset[object]()
+ self._register_tools()
+
+ def _register_tools(self) -> None:
+ """Register the list_agents and delegate_to_agent tools."""
+ manager = self._manager
+
+ @self._toolset.tool_plain
+ async def list_agents() -> dict[str, str]:
+ """List all external agents available for task delegation.
+
+ List external agents available for delegation.
+
+ Returns:
+ Dictionary mapping agent names to their descriptions.
+ """
+ return {
+ name: card.description or ""
+ for name, card in manager.list_agents().items()
+ }
+
+ @self._toolset.tool_plain
+ async def delegate_to_agent(agent_name: str, task: str) -> str:
+ """Delegate a task to an external A2A agent and return its response.
+
+ Delegate a task to an external agent.
+
+ Use this tool when a user's request matches the expertise of one of
+ the available external agents. Call ``list_agents`` first to see
+ which agents are available.
+
+ Args:
+ agent_name: Name of the agent to delegate to (from list_agents).
+ task: The task description or question to send to the agent.
+
+ Returns:
+ The agent's text response.
+ """
+ client = manager.get_client(agent_name)
+ if client is None:
+ available = ", ".join(manager.list_agents().keys())
+ raise ModelRetry(
+ f"Agent '{agent_name}' not found. Available: {available}"
+ )
+
+ try:
+ logger.debug("Delegating to agent '%s': %s", agent_name, task)
+ message = create_text_message_object(role=Role.user, content=task)
+ return await _send_and_collect(client, message)
+ except A2AClientError as e:
+ logger.warning(
+ "A2A delegation to '%s' failed: %s",
+ agent_name,
+ e,
+ )
+ return (
+ f"Delegation to agent '{agent_name}' failed: {e}. "
+ "Please try answering directly or inform the user."
+ )
+
+ def get_toolset(self) -> FunctionToolset[object]:
+ """Return the delegation toolset.
+
+ Returns:
+ FunctionToolset with list_agents and delegate_to_agent tools.
+ """
+ return self._toolset
+
+ def get_instructions(self) -> str | None:
+ """Return instructions about available external agents.
+
+ Returns:
+ System prompt text describing the delegation capability.
+ """
+ agents = self._manager.list_agents()
+ if not agents:
+ return None
+ lines = ["You can delegate tasks to these external agents when appropriate:"]
+ for name, card in agents.items():
+ skills_text = ""
+ if card.skills:
+ skill_names = [s.name for s in card.skills if s.name]
+ if skill_names:
+ skills_text = f" (skills: {', '.join(skill_names)})"
+ lines.append(
+ f"- {name}: {card.description or 'No description'}{skills_text}"
+ )
+ lines.append(
+ "Use list_agents to see current availability, "
+ "then delegate_to_agent to send a task."
+ )
+ return "\n".join(lines)
+
+
+async def _send_and_collect(client: Any, message: Message) -> str:
+ """Send a message to an A2A client and collect the text response.
+
+ Parameters:
+ client: A2A Client instance.
+ message: Message to send.
+
+ Returns:
+ Concatenated text from the agent's response.
+ """
+ text_parts: list[str] = []
+ async for event in client.send_message(message):
+ if isinstance(event, Message):
+ for part in event.parts:
+ if isinstance(part.root, TextPart):
+ text_parts.append(part.root.text)
+ elif isinstance(event, tuple):
+ _task, update_event = event
+ if isinstance(update_event, TaskArtifactUpdateEvent):
+ text_parts.append(_extract_text_from_artifact(update_event))
+ elif (
+ update_event is not None
+ and hasattr(update_event, "status")
+ and update_event.status.state == TaskState.failed
+ ):
+ fail_msg = "Agent task failed"
+ if update_event.status.message:
+ for part in update_event.status.message.parts:
+ if isinstance(part.root, TextPart):
+ fail_msg = part.root.text
+ break
+ return f"Delegation failed: {fail_msg}"
+ return "".join(text_parts) or "Agent returned no response."
diff --git a/src/a2a_client/manager.py b/src/a2a_client/manager.py
new file mode 100644
index 000000000..7c953a8da
--- /dev/null
+++ b/src/a2a_client/manager.py
@@ -0,0 +1,173 @@
+"""A2A client manager for managing connections to external A2A agents."""
+
+from typing import Any, Optional
+
+import httpx
+from a2a.client import (
+ A2AClientError,
+ ClientConfig,
+ ClientFactory,
+)
+from a2a.client.client import Client
+from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
+from a2a.types import AgentCard
+
+from log import get_logger
+from models.config import A2AAgentsConfiguration
+from utils.types import Singleton
+
+logger = get_logger(__name__)
+
+
+class _BearerTokenInterceptor(
+ ClientCallInterceptor
+): # pylint: disable=too-few-public-methods
+ """Interceptor that adds a static bearer token to all requests."""
+
+ def __init__(self, token: str) -> None:
+ """Initialize with the bearer token.
+
+ Parameters:
+ token: Bearer token value.
+ """
+ self._token = token
+
+ async def intercept(
+ self,
+ method_name: str,
+ request_payload: dict[str, Any],
+ http_kwargs: dict[str, Any],
+ agent_card: AgentCard | None,
+ context: ClientCallContext | None,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Add Authorization header to the request.
+
+ Parameters:
+ method_name: The RPC method name.
+ request_payload: The JSON-RPC request payload.
+ http_kwargs: HTTP request keyword arguments.
+ agent_card: The AgentCard for the client.
+ context: Call-specific context.
+
+ Returns:
+ Modified request payload and HTTP kwargs.
+ """
+ headers = http_kwargs.get("headers", {})
+ headers["Authorization"] = f"Bearer {self._token}"
+ http_kwargs["headers"] = headers
+ return request_payload, http_kwargs
+
+
+class A2AClientManager(metaclass=Singleton):
+ """Singleton manager for external A2A agent client connections.
+
+ Discovers agent cards at initialization, creates and caches Client
+ instances per configured agent, and manages their lifecycle.
+ """
+
+ def __init__(self) -> None:
+ """Initialize the client manager."""
+ self._clients: dict[str, Client] = {}
+ self._cards: dict[str, AgentCard] = {}
+ self._initialized: bool = False
+
+ @property
+ def is_initialized(self) -> bool:
+ """Return whether the manager has been initialized."""
+ return self._initialized
+
+ async def initialize(self, config: A2AAgentsConfiguration) -> None:
+ """Discover agent cards and create clients for all configured agents.
+
+ Parameters:
+ config: A2A clients configuration with agent endpoints.
+ """
+ if self._initialized:
+ return
+
+ for agent_config in config.agents:
+ name = agent_config.name
+ url = str(agent_config.url)
+ try:
+ interceptors: list[ClientCallInterceptor] = []
+ if agent_config.auth_token is not None:
+ token = agent_config.auth_token.get_secret_value()
+ interceptors.append(_BearerTokenInterceptor(token))
+
+ transport = httpx.AsyncHTTPTransport(retries=agent_config.max_retries)
+ httpx_client = httpx.AsyncClient(
+ timeout=httpx.Timeout(agent_config.timeout),
+ transport=transport,
+ )
+ client = await ClientFactory.connect(
+ url,
+ client_config=ClientConfig(
+ streaming=True, httpx_client=httpx_client
+ ),
+ interceptors=interceptors or None,
+ )
+ card = await client.get_card()
+ self._clients[name] = client
+ self._cards[name] = card
+ logger.info("Connected to A2A agent '%s' at %s", name, url)
+ except (A2AClientError, httpx.HTTPError, OSError) as e:
+ logger.warning(
+ "Failed to connect to A2A agent '%s' at %s: %s",
+ name,
+ url,
+ e,
+ )
+
+ self._initialized = True
+ logger.info(
+ "A2A client manager initialized with %d agent(s): %s",
+ len(self._clients),
+ list(self._clients.keys()),
+ )
+
+ def get_client(self, agent_name: str) -> Optional[Client]:
+ """Return the Client for a named agent.
+
+ Parameters:
+ agent_name: Name of the agent as configured.
+
+ Returns:
+ Client instance, or None if the agent is not available.
+ """
+ return self._clients.get(agent_name)
+
+ def get_card(self, agent_name: str) -> Optional[AgentCard]:
+ """Return the cached AgentCard for a named agent.
+
+ Parameters:
+ agent_name: Name of the agent as configured.
+
+ Returns:
+ AgentCard, or None if the agent is not available.
+ """
+ return self._cards.get(agent_name)
+
+ def list_agents(self) -> dict[str, AgentCard]:
+ """Return all cached agent cards.
+
+ Returns:
+ Dictionary mapping agent names to their AgentCards.
+ """
+ return dict(self._cards)
+
+ async def close(self) -> None:
+ """Close all client connections."""
+ for name, client in self._clients.items():
+ try:
+ if hasattr(client, "close"):
+ await client.close() # pyright: ignore[reportAttributeAccessIssue]
+ logger.info("Closed A2A client for agent '%s'", name)
+ except (A2AClientError, httpx.HTTPError, OSError) as e:
+ logger.warning(
+ "Error closing A2A client for agent '%s': %s",
+ name,
+ e,
+ )
+ self._clients.clear()
+ self._cards.clear()
+ self._initialized = False
diff --git a/src/app/main.py b/src/app/main.py
index 0cf61752c..cfe751280 100644
--- a/src/app/main.py
+++ b/src/app/main.py
@@ -14,6 +14,7 @@
from starlette.types import ASGIApp, Message, Receive, Scope, Send
import version
+from a2a_client.manager import A2AClientManager
from a2a_storage import A2AStorageFactory
from app import routers
from app.database import create_tables, initialize_database
@@ -132,6 +133,14 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
except APIConnectionError as e:
logger.warning("Failed to set up model metrics: %s", e, exc_info=True)
+ # Initialize A2A client connections to remote agents
+ if configuration.a2a_agents is not None:
+ logger.info(
+ "Connecting to A2A remote agents: %s",
+ [a.name for a in configuration.a2a_agents.agents],
+ )
+ await A2AClientManager().initialize(configuration.a2a_agents)
+
logger.info("App startup complete")
initialize_database()
@@ -143,6 +152,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
try:
await shutdown_background_topic_summary_tasks()
await A2AStorageFactory.cleanup()
+ await A2AClientManager().close()
finally:
# Flush pending Sentry events after cleanup so any errors during
# shutdown are captured before the process exits.
diff --git a/src/configuration.py b/src/configuration.py
index e95e89083..67cd1c4dc 100644
--- a/src/configuration.py
+++ b/src/configuration.py
@@ -13,6 +13,7 @@
from cache.cache_factory import CacheFactory
from log import get_logger
from models.config import (
+ A2AAgentsConfiguration,
A2AStateConfiguration,
ApprovalsConfiguration,
AuthenticationConfiguration,
@@ -427,6 +428,13 @@ def a2a_state(self) -> "A2AStateConfiguration":
raise LogicError("logic error: configuration is not loaded")
return self._configuration.a2a_state
+ @property
+ def a2a_agents(self) -> A2AAgentsConfiguration | None:
+ """Return A2A clients configuration."""
+ if self._configuration is None:
+ raise LogicError("logic error: configuration is not loaded")
+ return self._configuration.a2a_agents
+
@property
def conversation_cache(self) -> Cache:
"""Return the conversation cache.
diff --git a/src/models/config.py b/src/models/config.py
index 28ff23264..97161d397 100644
--- a/src/models/config.py
+++ b/src/models/config.py
@@ -2022,6 +2022,60 @@ def config(
return None
+class A2AAgentEndpointConfiguration(ConfigurationBase):
+ """Configuration for a single external A2A agent endpoint.
+
+ Attributes:
+ name: Agent identifier used in delegation tool calls.
+ url: Base URL of the A2A agent.
+ auth_token: Optional static bearer token for authenticating with this agent.
+ timeout: Request timeout in seconds for this agent.
+ max_retries: Maximum retry attempts on transient failures.
+ """
+
+ name: str = Field(
+ ...,
+ title="Agent name",
+ description="Unique identifier for this agent, used by the delegation tool.",
+ )
+ url: AnyHttpUrl = Field(
+ ...,
+ title="Agent URL",
+ description="Base URL of the external A2A agent.",
+ )
+ auth_token: Optional[SecretStr] = Field(
+ default=None,
+ title="Auth token",
+ description="Static bearer token for authenticating with this agent.",
+ )
+ timeout: PositiveInt = Field(
+ default=30,
+ title="Request timeout",
+ description="Timeout in seconds for requests to this agent. "
+ "Default is 30 seconds.",
+ )
+ max_retries: NonNegativeInt = Field(
+ default=3,
+ title="Maximum retries",
+ description="Maximum number of retry attempts on transient failures. "
+ "Set to 0 to disable retries.",
+ )
+
+
+class A2AAgentsConfiguration(ConfigurationBase):
+ """Configuration for external A2A agent connections.
+
+ Attributes:
+ agents: List of external A2A agent endpoint configurations.
+ """
+
+ agents: list[A2AAgentEndpointConfiguration] = Field(
+ default_factory=list,
+ title="A2A agent endpoints",
+ description="External A2A agents available for task delegation.",
+ )
+
+
class ByokRag(ConfigurationBase):
"""BYOK (Bring Your Own Knowledge) RAG configuration."""
@@ -2714,6 +2768,12 @@ class Configuration(ConfigurationBase):
description="Configuration for A2A protocol persistent state storage.",
)
+ a2a_agents: Optional[A2AAgentsConfiguration] = Field(
+ default=None,
+ title="A2A clients configuration",
+ description="External A2A agents available for task delegation.",
+ )
+
quota_handlers: QuotaHandlersConfiguration = Field(
default_factory=lambda: QuotaHandlersConfiguration(
sqlite=None, postgres=None, enable_token_history=False
diff --git a/src/utils/pydantic_ai_helpers.py b/src/utils/pydantic_ai_helpers.py
index 38bf71e92..ce6fdd953 100644
--- a/src/utils/pydantic_ai_helpers.py
+++ b/src/utils/pydantic_ai_helpers.py
@@ -11,6 +11,8 @@
from pydantic_ai.capabilities import AbstractCapability, AgentCapability
from pydantic_ai_skills import SkillsCapability
+from a2a_client.capability import A2ADelegationCapability
+from a2a_client.manager import A2AClientManager
from models.common.responses.responses_api_params import ResponsesApiParams
from models.config import SkillsConfiguration
from pydantic_ai_lightspeed.llamastack import (
@@ -128,6 +130,18 @@ def get_agent_capability_tools(
return tools
+def _a2a_delegation_capability() -> Optional[AgentCapability[object]]:
+ """Return an A2A delegation capability if the client manager is initialized.
+
+ Returns:
+ A2ADelegationCapability when external agents are configured, or None.
+ """
+ manager = A2AClientManager()
+ if not manager.is_initialized or not manager.list_agents():
+ return None
+ return A2ADelegationCapability(manager)
+
+
def _agent_capabilities(
skills: Optional[SkillsConfiguration],
no_tools: bool = False,
@@ -144,6 +158,8 @@ def _agent_capabilities(
capabilities: list[AgentCapability[object]] = []
if skills_capability := _skills_capability(skills):
capabilities.append(skills_capability)
+ if a2a_capability := _a2a_delegation_capability():
+ capabilities.append(a2a_capability)
if no_tools:
capabilities = [
capability
diff --git a/tests/unit/a2a_client/__init__.py b/tests/unit/a2a_client/__init__.py
new file mode 100644
index 000000000..2607d541c
--- /dev/null
+++ b/tests/unit/a2a_client/__init__.py
@@ -0,0 +1 @@
+"""Unit tests for the A2A client module."""
diff --git a/tests/unit/a2a_client/test_capability.py b/tests/unit/a2a_client/test_capability.py
new file mode 100644
index 000000000..b6ed2f4de
--- /dev/null
+++ b/tests/unit/a2a_client/test_capability.py
@@ -0,0 +1,231 @@
+"""Unit tests for A2ADelegationCapability."""
+
+# pylint: disable=redefined-outer-name
+
+from typing import Any
+
+import pytest
+from a2a.types import (
+ Artifact,
+ Message,
+ Part,
+ Role,
+ TaskArtifactUpdateEvent,
+ TaskState,
+ TaskStatus,
+ TaskStatusUpdateEvent,
+)
+from a2a.types import TextPart as A2ATextPart
+from pytest_mock import MockerFixture
+
+from a2a_client.capability import (
+ A2ADelegationCapability,
+ _extract_text_from_artifact,
+ _send_and_collect,
+)
+
+
+@pytest.fixture
+def mock_manager(mocker: MockerFixture) -> Any:
+ """Create a mock A2AClientManager."""
+ manager = mocker.MagicMock()
+
+ card1 = mocker.MagicMock()
+ card1.description = "A travel booking agent"
+ card1.skills = [mocker.MagicMock(name="book_flight")]
+ card1.skills[0].name = "book_flight"
+
+ card2 = mocker.MagicMock()
+ card2.description = "A code review agent"
+ card2.skills = []
+
+ manager.list_agents.return_value = {
+ "travel": card1,
+ "reviewer": card2,
+ }
+ manager.get_client.return_value = mocker.AsyncMock()
+ return manager
+
+
+@pytest.fixture
+def capability(mock_manager: Any) -> A2ADelegationCapability:
+ """Create an A2ADelegationCapability instance."""
+ return A2ADelegationCapability(mock_manager)
+
+
+class TestA2ADelegationCapability:
+ """Tests for the delegation capability."""
+
+ def test_get_toolset_returns_toolset_with_expected_tools(
+ self, capability: A2ADelegationCapability
+ ) -> None:
+ """Test that get_toolset returns a toolset with list_agents and delegate_to_agent."""
+ toolset = capability.get_toolset()
+ assert toolset is not None
+ tool_names = set(toolset.tools)
+ assert "list_agents" in tool_names
+ assert "delegate_to_agent" in tool_names
+
+ def test_get_instructions_includes_agents(
+ self, capability: A2ADelegationCapability
+ ) -> None:
+ """Test that instructions list available agents."""
+ instructions = capability.get_instructions()
+ assert instructions is not None
+ assert "travel" in instructions
+ assert "reviewer" in instructions
+ assert "travel booking agent" in instructions.lower()
+
+ def test_get_instructions_none_when_no_agents(self, mocker: MockerFixture) -> None:
+ """Test that instructions return None when no agents available."""
+ manager = mocker.MagicMock()
+ manager.list_agents.return_value = {}
+ cap = A2ADelegationCapability(manager)
+ assert cap.get_instructions() is None
+
+
+class TestExtractTextFromArtifact:
+ """Tests for artifact text extraction."""
+
+ def test_extracts_text_parts(self) -> None:
+ """Test extraction of text from artifact parts."""
+ event = TaskArtifactUpdateEvent(
+ task_id="t1",
+ context_id="c1",
+ artifact=Artifact(
+ artifact_id="a1",
+ parts=[
+ Part(root=A2ATextPart(text="Hello ")),
+ Part(root=A2ATextPart(text="world")),
+ ],
+ ),
+ last_chunk=True,
+ )
+ assert _extract_text_from_artifact(event) == "Hello world"
+
+ def test_empty_parts(self) -> None:
+ """Test extraction with no parts."""
+ event = TaskArtifactUpdateEvent(
+ task_id="t1",
+ context_id="c1",
+ artifact=Artifact(artifact_id="a1", parts=[]),
+ last_chunk=True,
+ )
+ assert _extract_text_from_artifact(event) == ""
+
+
+class TestSendAndCollect:
+ """Tests for the _send_and_collect helper."""
+
+ @pytest.mark.asyncio
+ async def test_collects_text_from_message_response(
+ self, mocker: MockerFixture
+ ) -> None:
+ """Test collecting text when agent returns a Message."""
+ mock_message = Message(
+ role=Role.agent,
+ parts=[Part(root=A2ATextPart(text="Agent response"))],
+ message_id="m1",
+ )
+
+ async def _mock_stream(_msg: Any) -> Any:
+ yield mock_message
+
+ mock_client = mocker.MagicMock()
+ mock_client.send_message = _mock_stream
+
+ result = await _send_and_collect(
+ mock_client,
+ Message(
+ role=Role.user,
+ parts=[Part(root=A2ATextPart(text="test"))],
+ message_id="m2",
+ ),
+ )
+ assert result == "Agent response"
+
+ @pytest.mark.asyncio
+ async def test_collects_text_from_artifact_event(
+ self, mocker: MockerFixture
+ ) -> None:
+ """Test collecting text from TaskArtifactUpdateEvent."""
+ artifact_event = TaskArtifactUpdateEvent(
+ task_id="t1",
+ context_id="c1",
+ artifact=Artifact(
+ artifact_id="a1",
+ parts=[Part(root=A2ATextPart(text="Artifact text"))],
+ ),
+ last_chunk=True,
+ )
+
+ async def _mock_stream(_msg: Any) -> Any:
+ yield (mocker.MagicMock(), artifact_event)
+
+ mock_client = mocker.MagicMock()
+ mock_client.send_message = _mock_stream
+
+ result = await _send_and_collect(
+ mock_client,
+ Message(
+ role=Role.user,
+ parts=[Part(root=A2ATextPart(text="test"))],
+ message_id="m1",
+ ),
+ )
+ assert result == "Artifact text"
+
+ @pytest.mark.asyncio
+ async def test_handles_failed_task(self, mocker: MockerFixture) -> None:
+ """Test that failed task status returns error message."""
+ fail_event = TaskStatusUpdateEvent(
+ task_id="t1",
+ context_id="c1",
+ status=TaskStatus(
+ state=TaskState.failed,
+ message=Message(
+ role=Role.agent,
+ parts=[Part(root=A2ATextPart(text="Something went wrong"))],
+ message_id="m1",
+ ),
+ ),
+ final=True,
+ )
+
+ async def _mock_stream(_msg: Any) -> Any:
+ yield (mocker.MagicMock(), fail_event)
+
+ mock_client = mocker.MagicMock()
+ mock_client.send_message = _mock_stream
+
+ result = await _send_and_collect(
+ mock_client,
+ Message(
+ role=Role.user,
+ parts=[Part(root=A2ATextPart(text="test"))],
+ message_id="m1",
+ ),
+ )
+ assert "Delegation failed" in result
+ assert "Something went wrong" in result
+
+ @pytest.mark.asyncio
+ async def test_empty_response(self, mocker: MockerFixture) -> None:
+ """Test that empty response returns fallback message."""
+
+ async def _mock_stream(_msg: Any) -> Any:
+ return
+ yield # pragma: no cover
+
+ mock_client = mocker.MagicMock()
+ mock_client.send_message = _mock_stream
+
+ result = await _send_and_collect(
+ mock_client,
+ Message(
+ role=Role.user,
+ parts=[Part(root=A2ATextPart(text="test"))],
+ message_id="m1",
+ ),
+ )
+ assert result == "Agent returned no response."
diff --git a/tests/unit/a2a_client/test_manager.py b/tests/unit/a2a_client/test_manager.py
new file mode 100644
index 000000000..172824bbd
--- /dev/null
+++ b/tests/unit/a2a_client/test_manager.py
@@ -0,0 +1,192 @@
+"""Unit tests for A2AClientManager."""
+
+# pylint: disable=redefined-outer-name
+# pylint: disable=protected-access
+# pylint: disable=too-few-public-methods
+
+from typing import Any
+
+import pytest
+from pytest_mock import MockerFixture
+
+from a2a_client.manager import A2AClientManager, _BearerTokenInterceptor
+
+
+@pytest.fixture(autouse=True)
+def _reset_singleton() -> Any:
+ """Reset the singleton between tests."""
+ from utils.types import Singleton # pylint: disable=import-outside-toplevel
+
+ if A2AClientManager in Singleton._instances:
+ del Singleton._instances[A2AClientManager]
+ yield
+ if A2AClientManager in Singleton._instances:
+ del Singleton._instances[A2AClientManager]
+
+
+@pytest.fixture
+def a2a_config(mocker: MockerFixture) -> Any:
+ """Create a mock A2AAgentsConfiguration."""
+ agent1 = mocker.MagicMock()
+ agent1.name = "test-agent"
+ agent1.url = "https://agent.example.com"
+ agent1.auth_token = None
+
+ config = mocker.MagicMock()
+ config.agents = [agent1]
+ return config
+
+
+@pytest.fixture
+def a2a_config_with_token(mocker: MockerFixture) -> Any:
+ """Create a mock config with auth token."""
+ agent1 = mocker.MagicMock()
+ agent1.name = "secure-agent"
+ agent1.url = "https://secure.example.com"
+ agent1.auth_token.get_secret_value.return_value = "test-token-123"
+
+ config = mocker.MagicMock()
+ config.agents = [agent1]
+ return config
+
+
+class TestA2AClientManager:
+ """Tests for the A2AClientManager singleton."""
+
+ def test_not_initialized_by_default(self) -> None:
+ """Test that manager starts uninitialized."""
+ manager = A2AClientManager()
+ assert not manager.is_initialized
+ assert manager.list_agents() == {}
+
+ @pytest.mark.asyncio
+ async def test_initialize_connects_to_agents(
+ self, mocker: MockerFixture, a2a_config: Any
+ ) -> None:
+ """Test that initialize discovers agents and creates clients."""
+ mock_card = mocker.MagicMock()
+ mock_card.description = "A test agent"
+ mock_client = mocker.AsyncMock()
+ mock_client.get_card = mocker.AsyncMock(return_value=mock_card)
+
+ connect_mock = mocker.patch(
+ "a2a_client.manager.ClientFactory.connect",
+ new=mocker.AsyncMock(return_value=mock_client),
+ )
+
+ manager = A2AClientManager()
+ await manager.initialize(a2a_config)
+
+ assert manager.is_initialized
+ assert "test-agent" in manager.list_agents()
+ assert manager.get_client("test-agent") is mock_client
+ assert manager.get_card("test-agent") is mock_card
+ connect_mock.assert_awaited_once()
+ assert "agent.example.com" in str(connect_mock.call_args[0][0])
+
+ @pytest.mark.asyncio
+ async def test_initialize_with_auth_token(
+ self, mocker: MockerFixture, a2a_config_with_token: Any
+ ) -> None:
+ """Test that auth token is passed as interceptor."""
+ mock_card = mocker.MagicMock()
+ mock_client = mocker.AsyncMock()
+ mock_client.get_card = mocker.AsyncMock(return_value=mock_card)
+
+ connect_mock = mocker.patch(
+ "a2a_client.manager.ClientFactory.connect",
+ new=mocker.AsyncMock(return_value=mock_client),
+ )
+
+ manager = A2AClientManager()
+ await manager.initialize(a2a_config_with_token)
+
+ call_kwargs = connect_mock.call_args[1]
+ interceptors = call_kwargs.get("interceptors")
+ assert interceptors is not None
+ assert len(interceptors) == 1
+ assert isinstance(interceptors[0], _BearerTokenInterceptor)
+ assert interceptors[0]._token == "test-token-123"
+
+ @pytest.mark.asyncio
+ async def test_initialize_handles_connection_failure(
+ self, mocker: MockerFixture, a2a_config: Any
+ ) -> None:
+ """Test that failed connections are logged but don't crash."""
+ from a2a.client import A2AClientError # pylint: disable=import-outside-toplevel
+
+ mocker.patch(
+ "a2a_client.manager.ClientFactory.connect",
+ new=mocker.AsyncMock(side_effect=A2AClientError("Connection refused")),
+ )
+
+ manager = A2AClientManager()
+ await manager.initialize(a2a_config)
+
+ assert manager.is_initialized
+ assert manager.list_agents() == {}
+ assert manager.get_client("test-agent") is None
+
+ @pytest.mark.asyncio
+ async def test_initialize_idempotent(
+ self, mocker: MockerFixture, a2a_config: Any
+ ) -> None:
+ """Test that calling initialize twice is a no-op."""
+ mock_client = mocker.AsyncMock()
+ mock_client.get_card = mocker.AsyncMock(return_value=mocker.MagicMock())
+
+ connect_mock = mocker.patch(
+ "a2a_client.manager.ClientFactory.connect",
+ new=mocker.AsyncMock(return_value=mock_client),
+ )
+
+ manager = A2AClientManager()
+ await manager.initialize(a2a_config)
+ await manager.initialize(a2a_config)
+
+ assert connect_mock.await_count == 1
+
+ @pytest.mark.asyncio
+ async def test_close_cleans_up(
+ self, mocker: MockerFixture, a2a_config: Any
+ ) -> None:
+ """Test that close disconnects all clients and resets state."""
+ mock_client = mocker.AsyncMock()
+ mock_client.get_card = mocker.AsyncMock(return_value=mocker.MagicMock())
+
+ mocker.patch(
+ "a2a_client.manager.ClientFactory.connect",
+ new=mocker.AsyncMock(return_value=mock_client),
+ )
+
+ manager = A2AClientManager()
+ await manager.initialize(a2a_config)
+ assert manager.is_initialized
+
+ await manager.close()
+ assert not manager.is_initialized
+ assert manager.list_agents() == {}
+ mock_client.close.assert_awaited_once()
+
+ def test_get_client_unknown_agent(self) -> None:
+ """Test that get_client returns None for unknown agent."""
+ manager = A2AClientManager()
+ assert manager.get_client("nonexistent") is None
+
+
+class TestBearerTokenInterceptor:
+ """Tests for the bearer token interceptor."""
+
+ @pytest.mark.asyncio
+ async def test_adds_authorization_header(self) -> None:
+ """Test that interceptor adds Bearer token header."""
+ interceptor = _BearerTokenInterceptor("my-token")
+ payload: dict[str, Any] = {"method": "test"}
+ http_kwargs: dict[str, Any] = {}
+
+ result_payload, result_kwargs = await interceptor.intercept(
+ "message/send", payload, http_kwargs, None, None
+ )
+
+ assert result_kwargs["headers"]["Authorization"] == "Bearer my-token"
+ assert result_payload == payload
diff --git a/tests/unit/models/config/test_dump_configuration.py b/tests/unit/models/config/test_dump_configuration.py
index e1cd67816..df0a9f538 100644
--- a/tests/unit/models/config/test_dump_configuration.py
+++ b/tests/unit/models/config/test_dump_configuration.py
@@ -209,6 +209,7 @@ def test_dump_configuration_minimal_cfg(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -432,6 +433,7 @@ def test_dump_configuration_valid_values(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -806,6 +808,7 @@ def test_dump_configuration_with_quota_limiters(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -1064,6 +1067,7 @@ def test_dump_configuration_with_quota_limiters_different_values(
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -1302,6 +1306,7 @@ def test_dump_configuration_byok(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -1520,6 +1525,7 @@ def test_dump_configuration_pg_namespace(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -1898,6 +1904,7 @@ def test_dump_configuration_allow_degraded_mode(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -2122,6 +2129,7 @@ def test_dump_configuration_max_retries_settings(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -2346,6 +2354,7 @@ def test_dump_configuration_retry_count_settings(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],
@@ -2577,6 +2586,7 @@ def test_dump_configuration_specific_compaction_values(tmp_path: Path) -> None:
"sqlite": None,
"postgres": None,
},
+ "a2a_agents": None,
"azure_entra_id": None,
"rag": {
"inline": [],