mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: basic python a2a support (#906)
* basic python a2a support * fixes * small fixes --------- Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
fe6492ce60
commit
c102706146
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,21 @@
|
||||
# Get Started with Microsoft Agent Framework A2A
|
||||
|
||||
Please install this package as the extra for `agent-framework`:
|
||||
|
||||
```bash
|
||||
pip install agent-framework[a2a]
|
||||
```
|
||||
|
||||
## A2A Agent Integration
|
||||
|
||||
The A2A agent integration enables communication with remote A2A-compliant agents using the standardized A2A protocol. This allows your Agent Framework applications to connect to agents running on different platforms, languages, or services.
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the [A2A agent examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/a2a/) which demonstrate:
|
||||
|
||||
- Connecting to remote A2A agents
|
||||
- Sending messages and receiving responses
|
||||
- Handling different content types (text, files, data)
|
||||
- Streaming responses and real-time interaction
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._agent import A2AAgent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"A2AAgent",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,383 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Sequence
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from a2a.client import Client, ClientConfig, ClientFactory, minimal_agent_card
|
||||
from a2a.types import (
|
||||
AgentCard,
|
||||
Artifact,
|
||||
FilePart,
|
||||
FileWithBytes,
|
||||
FileWithUri,
|
||||
Message,
|
||||
Task,
|
||||
TaskState,
|
||||
TextPart,
|
||||
TransportProtocol,
|
||||
)
|
||||
from a2a.types import Message as A2AMessage
|
||||
from a2a.types import Part as A2APart
|
||||
from a2a.types import Role as A2ARole
|
||||
from agent_framework import (
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
BaseAgent,
|
||||
ChatMessage,
|
||||
Contents,
|
||||
DataContent,
|
||||
Role,
|
||||
TextContent,
|
||||
UriContent,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
|
||||
__all__ = ["A2AAgent"]
|
||||
|
||||
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<base64_data>[A-Za-z0-9+/=]+)$")
|
||||
TERMINAL_TASK_STATES = [
|
||||
TaskState.completed,
|
||||
TaskState.failed,
|
||||
TaskState.canceled,
|
||||
TaskState.rejected,
|
||||
]
|
||||
|
||||
|
||||
def _get_uri_data(uri: str) -> str:
|
||||
match = URI_PATTERN.match(uri)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid data URI format: {uri}")
|
||||
|
||||
return match.group("base64_data")
|
||||
|
||||
|
||||
class A2AAgent(BaseAgent):
|
||||
"""Agent-to-Agent (A2A) protocol implementation.
|
||||
|
||||
Wraps an A2A Client to connect the Agent Framework with external A2A-compliant agents
|
||||
via HTTP/JSON-RPC. Converts framework ChatMessages to A2A Messages on send, and converts
|
||||
A2A responses (Messages/Tasks) back to framework types. Inherits BaseAgent capabilities
|
||||
while managing the underlying A2A protocol communication.
|
||||
|
||||
Can be initialized with a URL, AgentCard, or existing A2A Client instance.
|
||||
"""
|
||||
|
||||
client: Client
|
||||
_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
description: str | None = None,
|
||||
agent_card: AgentCard | None = None,
|
||||
url: str | None = None,
|
||||
client: Client | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize the A2AAgent.
|
||||
|
||||
Args:
|
||||
name: The name of the agent.
|
||||
id: The unique identifier for the agent, will be created automatically if not provided.
|
||||
description: A brief description of the agent's purpose.
|
||||
agent_card: The agent card for the agent.
|
||||
url: The URL for the A2A server.
|
||||
client: The A2A client for the agent.
|
||||
http_client: Optional httpx.AsyncClient to use.
|
||||
"""
|
||||
if client is None:
|
||||
if agent_card is None:
|
||||
if url is None:
|
||||
raise ValueError("Either agent_card or url must be provided")
|
||||
# Create minimal agent card from URL
|
||||
agent_card = minimal_agent_card(url, [TransportProtocol.jsonrpc])
|
||||
|
||||
# Create or use provided httpx client
|
||||
if http_client is None:
|
||||
timeout = httpx.Timeout(
|
||||
connect=10.0, # 10 seconds to establish connection
|
||||
read=60.0, # 60 seconds to read response (A2A operations can take time)
|
||||
write=10.0, # 10 seconds to send request
|
||||
pool=5.0, # 5 seconds to get connection from pool
|
||||
)
|
||||
headers = prepend_agent_framework_to_user_agent()
|
||||
http_client = httpx.AsyncClient(timeout=timeout, headers=headers)
|
||||
self._http_client = http_client # Store for cleanup
|
||||
|
||||
# Create A2A client using factory
|
||||
config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
supported_transports=[TransportProtocol.jsonrpc],
|
||||
)
|
||||
factory = ClientFactory(config)
|
||||
client = factory.create(agent_card)
|
||||
|
||||
args: dict[str, Any] = {"client": client}
|
||||
if name:
|
||||
args["name"] = name
|
||||
if id:
|
||||
args["id"] = id
|
||||
if description:
|
||||
args["description"] = description
|
||||
super().__init__(**args)
|
||||
|
||||
async def __aenter__(self) -> "A2AAgent":
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: Any,
|
||||
) -> None:
|
||||
"""Async context manager exit with httpx client cleanup."""
|
||||
# Close our httpx client if we created it
|
||||
if self._http_client is not None:
|
||||
await self._http_client.aclose()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentRunResponse:
|
||||
"""Get a response from the agent.
|
||||
|
||||
This method returns the final result of the agent's execution
|
||||
as a single AgentRunResponse object. The caller is blocked until
|
||||
the final result is available.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent.
|
||||
thread: The conversation thread associated with the message(s).
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
An agent response item.
|
||||
"""
|
||||
# Collect all updates and use framework to consolidate updates into response
|
||||
updates = [update async for update in self.run_stream(messages, thread=thread, **kwargs)]
|
||||
return AgentRunResponse.from_agent_run_response_updates(updates)
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
"""Run the agent as a stream.
|
||||
|
||||
This method will return the intermediate steps and final results of the
|
||||
agent's execution as a stream of AgentRunResponseUpdate objects to the caller.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent.
|
||||
thread: The conversation thread associated with the message(s).
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Yields:
|
||||
An agent response item.
|
||||
"""
|
||||
messages = self._normalize_messages(messages)
|
||||
a2a_message = self._chat_message_to_a2a_message(messages[-1])
|
||||
|
||||
response_stream = self.client.send_message(a2a_message)
|
||||
|
||||
async for item in response_stream:
|
||||
if isinstance(item, Message):
|
||||
# Process A2A Message
|
||||
contents = self._a2a_parts_to_contents(item.parts)
|
||||
yield AgentRunResponseUpdate(
|
||||
contents=contents,
|
||||
role=Role.ASSISTANT if item.role == A2ARole.agent else Role.USER,
|
||||
response_id=str(getattr(item, "message_id", uuid.uuid4())),
|
||||
raw_representation=item,
|
||||
)
|
||||
elif isinstance(item, tuple) and len(item) == 2: # ClientEvent = (Task, UpdateEvent)
|
||||
task, _update_event = item
|
||||
if isinstance(task, Task) and task.status.state in TERMINAL_TASK_STATES:
|
||||
# Convert Task artifacts to ChatMessages and yield as separate updates
|
||||
task_messages = self._task_to_chat_messages(task)
|
||||
if task_messages:
|
||||
for message in task_messages:
|
||||
# Use the artifact's ID from raw_representation as message_id for unique identification
|
||||
artifact_id = getattr(message.raw_representation, "artifact_id", None)
|
||||
yield AgentRunResponseUpdate(
|
||||
contents=message.contents,
|
||||
role=message.role,
|
||||
response_id=task.id,
|
||||
message_id=artifact_id,
|
||||
raw_representation=task,
|
||||
)
|
||||
else:
|
||||
# Empty task
|
||||
yield AgentRunResponseUpdate(
|
||||
contents=[],
|
||||
role=Role.ASSISTANT,
|
||||
response_id=task.id,
|
||||
raw_representation=task,
|
||||
)
|
||||
else:
|
||||
# Unknown response type
|
||||
msg = f"Only Message and Task responses are supported from A2A agents. Received: {type(item)}"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def _chat_message_to_a2a_message(self, message: ChatMessage) -> A2AMessage:
|
||||
"""Convert a ChatMessage to an A2A Message.
|
||||
|
||||
Transforms Agent Framework ChatMessage objects into A2A protocol Messages by:
|
||||
- Converting all message contents to appropriate A2A Part types
|
||||
- Mapping text content to TextPart objects
|
||||
- Converting file references (URI/data/hosted_file) to FilePart objects
|
||||
- Preserving metadata and additional properties from the original message
|
||||
- Setting the role to 'user' as framework messages are treated as user input
|
||||
"""
|
||||
parts: list[A2APart] = []
|
||||
if not message.contents:
|
||||
raise ValueError("ChatMessage.contents is empty; cannot convert to A2AMessage.")
|
||||
|
||||
# Process ALL contents
|
||||
for content in message.contents:
|
||||
match content.type:
|
||||
case "text":
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=TextPart(
|
||||
text=content.text,
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
)
|
||||
)
|
||||
case "error":
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=TextPart(
|
||||
text=content.message or "An error occurred.",
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
)
|
||||
)
|
||||
case "uri":
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithUri(
|
||||
uri=content.uri,
|
||||
mime_type=content.media_type,
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
)
|
||||
)
|
||||
case "data":
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithBytes(
|
||||
bytes=_get_uri_data(content.uri),
|
||||
mime_type=content.media_type,
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
)
|
||||
)
|
||||
case "hosted_file":
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithUri(
|
||||
uri=content.file_id,
|
||||
mime_type=None, # HostedFileContent doesn't specify media_type
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
)
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unknown content type: {content.type}")
|
||||
|
||||
return A2AMessage(
|
||||
role=A2ARole("user"),
|
||||
parts=parts,
|
||||
message_id=message.message_id or uuid.uuid4().hex,
|
||||
metadata=message.additional_properties,
|
||||
)
|
||||
|
||||
def _a2a_parts_to_contents(self, parts: Sequence[A2APart]) -> list[Contents]:
|
||||
"""Convert A2A Parts to Agent Framework Contents.
|
||||
|
||||
Transforms A2A protocol Parts into framework-native Content objects,
|
||||
handling text, file (URI/bytes), and data parts with metadata preservation.
|
||||
"""
|
||||
contents: list[Contents] = []
|
||||
for part in parts:
|
||||
inner_part = part.root
|
||||
match inner_part.kind:
|
||||
case "text":
|
||||
contents.append(
|
||||
TextContent(
|
||||
text=inner_part.text,
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
)
|
||||
)
|
||||
case "file":
|
||||
if isinstance(inner_part.file, FileWithUri):
|
||||
contents.append(
|
||||
UriContent(
|
||||
uri=inner_part.file.uri,
|
||||
media_type=inner_part.file.mime_type or "",
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
)
|
||||
)
|
||||
elif isinstance(inner_part.file, FileWithBytes):
|
||||
contents.append(
|
||||
DataContent(
|
||||
data=base64.b64decode(inner_part.file.bytes),
|
||||
media_type=inner_part.file.mime_type or "",
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
)
|
||||
)
|
||||
case "data":
|
||||
contents.append(
|
||||
TextContent(
|
||||
text=json.dumps(inner_part.data),
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
)
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unknown Part kind: {inner_part.kind}")
|
||||
return contents
|
||||
|
||||
def _task_to_chat_messages(self, task: Task) -> list[ChatMessage]:
|
||||
"""Convert A2A Task artifacts to ChatMessages with ASSISTANT role."""
|
||||
messages: list[ChatMessage] = []
|
||||
|
||||
if task.artifacts is not None:
|
||||
for artifact in task.artifacts:
|
||||
messages.append(self._artifact_to_chat_message(artifact))
|
||||
|
||||
return messages
|
||||
|
||||
def _artifact_to_chat_message(self, artifact: Artifact) -> ChatMessage:
|
||||
"""Convert A2A Artifact to ChatMessage using part contents."""
|
||||
contents = self._a2a_parts_to_contents(artifact.parts)
|
||||
return ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=contents,
|
||||
raw_representation=artifact,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
[project]
|
||||
name = "agent-framework-a2a"
|
||||
description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "0.1.0b1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Framework :: Pydantic :: 2",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework",
|
||||
"a2a-sdk>=0.3.5",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extend = "../../pyproject.toml"
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_a2a"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
|
||||
test = "pytest --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.9,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,498 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from a2a.types import Artifact, DataPart, FilePart, FileWithUri, Message, Part, Task, TaskState, TaskStatus, TextPart
|
||||
from a2a.types import Role as A2ARole
|
||||
from agent_framework import (
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
ChatMessage,
|
||||
DataContent,
|
||||
ErrorContent,
|
||||
HostedFileContent,
|
||||
Role,
|
||||
TextContent,
|
||||
UriContent,
|
||||
)
|
||||
from agent_framework.a2a import A2AAgent
|
||||
from pytest import fixture, raises
|
||||
|
||||
from agent_framework_a2a._agent import _get_uri_data # type: ignore
|
||||
|
||||
|
||||
class MockA2AClient:
|
||||
"""Mock implementation of A2A Client for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.call_count: int = 0
|
||||
self.responses: list[Any] = []
|
||||
|
||||
def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None:
|
||||
"""Add a mock Message response."""
|
||||
|
||||
# Create actual TextPart instance and wrap it in Part
|
||||
text_part = Part(root=TextPart(text=text))
|
||||
|
||||
# Create actual Message instance
|
||||
message = Message(
|
||||
message_id=message_id, role=A2ARole.agent if role == "agent" else A2ARole.user, parts=[text_part]
|
||||
)
|
||||
self.responses.append(message)
|
||||
|
||||
def add_task_response(self, task_id: str, artifacts: list[dict[str, Any]]) -> None:
|
||||
"""Add a mock Task response."""
|
||||
# Create mock artifacts
|
||||
mock_artifacts = []
|
||||
for artifact_data in artifacts:
|
||||
# Create actual TextPart instance and wrap it in Part
|
||||
text_part = Part(root=TextPart(text=artifact_data.get("content", "Test content")))
|
||||
|
||||
artifact = Artifact(
|
||||
artifact_id=artifact_data.get("id", str(uuid4())),
|
||||
name=artifact_data.get("name", "test-artifact"),
|
||||
description=artifact_data.get("description", "Test artifact"),
|
||||
parts=[text_part],
|
||||
)
|
||||
mock_artifacts.append(artifact)
|
||||
|
||||
# Create task status
|
||||
status = TaskStatus(state=TaskState.completed, message=None)
|
||||
|
||||
# Create actual Task instance
|
||||
task = Task(
|
||||
id=task_id, context_id="test-context", status=status, artifacts=mock_artifacts if mock_artifacts else None
|
||||
)
|
||||
|
||||
# Mock the ClientEvent tuple format
|
||||
update_event = None # No specific update event for completed tasks
|
||||
client_event = (task, update_event)
|
||||
self.responses.append(client_event)
|
||||
|
||||
async def send_message(self, message: Any) -> AsyncIterator[Any]:
|
||||
"""Mock send_message method that yields responses."""
|
||||
self.call_count += 1
|
||||
|
||||
if self.responses:
|
||||
response = self.responses.pop(0)
|
||||
yield response
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_a2a_client() -> MockA2AClient:
|
||||
"""Fixture that provides a mock A2A client."""
|
||||
return MockA2AClient()
|
||||
|
||||
|
||||
@fixture
|
||||
def a2a_agent(mock_a2a_client: MockA2AClient) -> A2AAgent:
|
||||
"""Fixture that provides an A2AAgent with a mock client."""
|
||||
return A2AAgent.model_construct(name="Test Agent", id="test-agent", client=mock_a2a_client, _http_client=None)
|
||||
|
||||
|
||||
def test_a2a_agent_initialization_with_client(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test A2AAgent initialization with provided client."""
|
||||
# Use model_construct to bypass Pydantic validation for mock objects
|
||||
agent = A2AAgent.model_construct(
|
||||
name="Test Agent", id="test-agent-123", description="A test agent", client=mock_a2a_client, _http_client=None
|
||||
)
|
||||
|
||||
assert agent.name == "Test Agent"
|
||||
assert agent.id == "test-agent-123"
|
||||
assert agent.description == "A test agent"
|
||||
assert agent.client == mock_a2a_client
|
||||
|
||||
|
||||
def test_a2a_agent_initialization_without_client_raises_error() -> None:
|
||||
"""Test A2AAgent initialization without client or URL raises ValueError."""
|
||||
with raises(ValueError, match="Either agent_card or url must be provided"):
|
||||
A2AAgent(name="Test Agent")
|
||||
|
||||
|
||||
async def test_run_with_message_response(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test run() method with immediate Message response."""
|
||||
mock_a2a_client.add_message_response("msg-123", "Hello from agent!", "agent")
|
||||
|
||||
response = await a2a_agent.run("Hello agent")
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].role == Role.ASSISTANT
|
||||
assert response.messages[0].text == "Hello from agent!"
|
||||
assert response.response_id == "msg-123"
|
||||
assert mock_a2a_client.call_count == 1
|
||||
|
||||
|
||||
async def test_run_with_task_response_single_artifact(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test run() method with Task response containing single artifact."""
|
||||
artifacts = [{"id": "art-1", "content": "Generated report content"}]
|
||||
mock_a2a_client.add_task_response("task-456", artifacts)
|
||||
|
||||
response = await a2a_agent.run("Generate a report")
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].role == Role.ASSISTANT
|
||||
assert response.messages[0].text == "Generated report content"
|
||||
assert response.response_id == "task-456"
|
||||
assert mock_a2a_client.call_count == 1
|
||||
|
||||
|
||||
async def test_run_with_task_response_multiple_artifacts(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test run() method with Task response containing multiple artifacts."""
|
||||
artifacts = [
|
||||
{"id": "art-1", "content": "First artifact content"},
|
||||
{"id": "art-2", "content": "Second artifact content"},
|
||||
{"id": "art-3", "content": "Third artifact content"},
|
||||
]
|
||||
mock_a2a_client.add_task_response("task-789", artifacts)
|
||||
|
||||
response = await a2a_agent.run("Generate multiple outputs")
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 3
|
||||
|
||||
assert response.messages[0].text == "First artifact content"
|
||||
assert response.messages[1].text == "Second artifact content"
|
||||
assert response.messages[2].text == "Third artifact content"
|
||||
|
||||
# All should be assistant messages
|
||||
for message in response.messages:
|
||||
assert message.role == Role.ASSISTANT
|
||||
|
||||
assert response.response_id == "task-789"
|
||||
|
||||
|
||||
async def test_run_with_task_response_no_artifacts(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test run() method with Task response containing no artifacts."""
|
||||
mock_a2a_client.add_task_response("task-empty", [])
|
||||
|
||||
response = await a2a_agent.run("Do something with no output")
|
||||
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert response.response_id == "task-empty"
|
||||
|
||||
|
||||
async def test_run_with_unknown_response_type_raises_error(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test run() method with unknown response type raises NotImplementedError."""
|
||||
mock_a2a_client.responses.append("invalid_response")
|
||||
|
||||
with raises(NotImplementedError, match="Only Message and Task responses are supported"):
|
||||
await a2a_agent.run("Test message")
|
||||
|
||||
|
||||
def test_task_to_chat_messages_empty_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _task_to_chat_messages with task containing no artifacts."""
|
||||
task = MagicMock()
|
||||
task.artifacts = None
|
||||
|
||||
result = a2a_agent._task_to_chat_messages(task)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_task_to_chat_messages_with_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _task_to_chat_messages with task containing artifacts."""
|
||||
task = MagicMock()
|
||||
|
||||
# Create mock artifacts
|
||||
artifact1 = MagicMock()
|
||||
artifact1.artifact_id = "art-1"
|
||||
text_part1 = MagicMock()
|
||||
text_part1.root = MagicMock()
|
||||
text_part1.root.kind = "text"
|
||||
text_part1.root.text = "Content 1"
|
||||
text_part1.root.metadata = None
|
||||
artifact1.parts = [text_part1]
|
||||
|
||||
artifact2 = MagicMock()
|
||||
artifact2.artifact_id = "art-2"
|
||||
text_part2 = MagicMock()
|
||||
text_part2.root = MagicMock()
|
||||
text_part2.root.kind = "text"
|
||||
text_part2.root.text = "Content 2"
|
||||
text_part2.root.metadata = None
|
||||
artifact2.parts = [text_part2]
|
||||
|
||||
task.artifacts = [artifact1, artifact2]
|
||||
|
||||
result = a2a_agent._task_to_chat_messages(task)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "Content 1"
|
||||
assert result[1].text == "Content 2"
|
||||
assert all(msg.role == Role.ASSISTANT for msg in result)
|
||||
|
||||
|
||||
def test_artifact_to_chat_message(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _artifact_to_chat_message conversion."""
|
||||
artifact = MagicMock()
|
||||
artifact.artifact_id = "test-artifact"
|
||||
|
||||
text_part = MagicMock()
|
||||
text_part.root = MagicMock()
|
||||
text_part.root.kind = "text"
|
||||
text_part.root.text = "Artifact content"
|
||||
text_part.root.metadata = None
|
||||
|
||||
artifact.parts = [text_part]
|
||||
|
||||
result = a2a_agent._artifact_to_chat_message(artifact)
|
||||
|
||||
assert isinstance(result, ChatMessage)
|
||||
assert result.role == Role.ASSISTANT
|
||||
assert result.text == "Artifact content"
|
||||
assert result.raw_representation == artifact
|
||||
|
||||
|
||||
def test_get_uri_data_valid_uri() -> None:
|
||||
"""Test _get_uri_data with valid data URI."""
|
||||
|
||||
uri = "data:application/json;base64,eyJ0ZXN0IjoidmFsdWUifQ=="
|
||||
result = _get_uri_data(uri)
|
||||
assert result == "eyJ0ZXN0IjoidmFsdWUifQ=="
|
||||
|
||||
|
||||
def test_get_uri_data_invalid_uri() -> None:
|
||||
"""Test _get_uri_data with invalid URI format."""
|
||||
|
||||
with raises(ValueError, match="Invalid data URI format"):
|
||||
_get_uri_data("not-a-valid-data-uri")
|
||||
|
||||
|
||||
def test_a2a_parts_to_contents_conversion(a2a_agent: A2AAgent) -> None:
|
||||
"""Test A2A parts to contents conversion."""
|
||||
|
||||
agent = A2AAgent.model_construct(name="Test Agent", client=MockA2AClient(), _http_client=None)
|
||||
|
||||
# Create A2A parts
|
||||
parts = [Part(root=TextPart(text="First part")), Part(root=TextPart(text="Second part"))]
|
||||
|
||||
# Convert to contents
|
||||
contents = agent._a2a_parts_to_contents(parts)
|
||||
|
||||
# Verify conversion
|
||||
assert len(contents) == 2
|
||||
assert isinstance(contents[0], TextContent)
|
||||
assert isinstance(contents[1], TextContent)
|
||||
assert contents[0].text == "First part"
|
||||
assert contents[1].text == "Second part"
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_error_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _chat_message_to_a2a_message with ErrorContent."""
|
||||
|
||||
# Create ChatMessage with ErrorContent
|
||||
error_content = ErrorContent(message="Test error message")
|
||||
message = ChatMessage(role=Role.USER, contents=[error_content])
|
||||
|
||||
# Convert to A2A message
|
||||
a2a_message = a2a_agent._chat_message_to_a2a_message(message)
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
assert a2a_message.parts[0].root.text == "Test error message"
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_uri_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _chat_message_to_a2a_message with UriContent."""
|
||||
|
||||
# Create ChatMessage with UriContent
|
||||
uri_content = UriContent(uri="http://example.com/file.pdf", media_type="application/pdf")
|
||||
message = ChatMessage(role=Role.USER, contents=[uri_content])
|
||||
|
||||
# Convert to A2A message
|
||||
a2a_message = a2a_agent._chat_message_to_a2a_message(message)
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
assert a2a_message.parts[0].root.file.uri == "http://example.com/file.pdf"
|
||||
assert a2a_message.parts[0].root.file.mime_type == "application/pdf"
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_data_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _chat_message_to_a2a_message with DataContent."""
|
||||
|
||||
# Create ChatMessage with DataContent (base64 data URI)
|
||||
data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
|
||||
message = ChatMessage(role=Role.USER, contents=[data_content])
|
||||
|
||||
# Convert to A2A message
|
||||
a2a_message = a2a_agent._chat_message_to_a2a_message(message)
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
assert a2a_message.parts[0].root.file.bytes == "SGVsbG8gV29ybGQ="
|
||||
assert a2a_message.parts[0].root.file.mime_type == "text/plain"
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_empty_contents_raises_error(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _chat_message_to_a2a_message with empty contents raises ValueError."""
|
||||
# Create ChatMessage with no contents
|
||||
message = ChatMessage(role=Role.USER, contents=[])
|
||||
|
||||
# Should raise ValueError for empty contents
|
||||
with raises(ValueError, match="ChatMessage.contents is empty"):
|
||||
a2a_agent._chat_message_to_a2a_message(message)
|
||||
|
||||
|
||||
async def test_run_stream_with_message_response(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test run_stream() method with immediate Message response."""
|
||||
mock_a2a_client.add_message_response("msg-stream-123", "Streaming response from agent!", "agent")
|
||||
|
||||
# Collect streaming updates
|
||||
updates: list[AgentRunResponseUpdate] = []
|
||||
async for update in a2a_agent.run_stream("Hello agent"):
|
||||
updates.append(update)
|
||||
|
||||
# Verify streaming response
|
||||
assert len(updates) == 1
|
||||
assert isinstance(updates[0], AgentRunResponseUpdate)
|
||||
assert updates[0].role == Role.ASSISTANT
|
||||
assert len(updates[0].contents) == 1
|
||||
|
||||
content = updates[0].contents[0]
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "Streaming response from agent!"
|
||||
|
||||
assert updates[0].response_id == "msg-stream-123"
|
||||
assert mock_a2a_client.call_count == 1
|
||||
|
||||
|
||||
async def test_context_manager_cleanup() -> None:
|
||||
"""Test context manager cleanup of http client."""
|
||||
|
||||
# Create mock http client that tracks aclose calls
|
||||
mock_http_client = AsyncMock()
|
||||
mock_a2a_client = MagicMock()
|
||||
|
||||
agent = A2AAgent.model_construct(client=mock_a2a_client, _http_client=mock_http_client)
|
||||
|
||||
# Test context manager cleanup
|
||||
async with agent:
|
||||
pass
|
||||
|
||||
# Verify aclose was called
|
||||
mock_http_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
async def test_context_manager_no_cleanup_when_no_http_client() -> None:
|
||||
"""Test context manager when _http_client is None."""
|
||||
|
||||
mock_a2a_client = MagicMock()
|
||||
|
||||
agent = A2AAgent.model_construct(client=mock_a2a_client, _http_client=None)
|
||||
|
||||
# This should not raise any errors
|
||||
async with agent:
|
||||
pass
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_multiple_contents() -> None:
|
||||
"""Test conversion of ChatMessage with multiple contents."""
|
||||
|
||||
agent = A2AAgent.model_construct(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create message with multiple content types
|
||||
message = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[
|
||||
TextContent(text="Here's the analysis:"),
|
||||
DataContent(data=b"binary data", media_type="application/octet-stream"),
|
||||
UriContent(uri="https://example.com/image.png", media_type="image/png"),
|
||||
TextContent(text='{"structured": "data"}'),
|
||||
],
|
||||
)
|
||||
|
||||
result = agent._chat_message_to_a2a_message(message)
|
||||
|
||||
# Should have converted all 4 contents to parts
|
||||
assert len(result.parts) == 4
|
||||
|
||||
# Check each part type
|
||||
assert result.parts[0].root.kind == "text" # Regular text
|
||||
assert result.parts[1].root.kind == "file" # Binary data
|
||||
assert result.parts[2].root.kind == "file" # URI content
|
||||
assert result.parts[3].root.kind == "text" # JSON text remains as text (no parsing)
|
||||
|
||||
|
||||
def test_a2a_parts_to_contents_with_data_part() -> None:
|
||||
"""Test conversion of A2A DataPart."""
|
||||
|
||||
agent = A2AAgent.model_construct(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create DataPart
|
||||
data_part = Part(root=DataPart(data={"key": "value", "number": 42}, metadata={"source": "test"}))
|
||||
|
||||
contents = agent._a2a_parts_to_contents([data_part])
|
||||
|
||||
assert len(contents) == 1
|
||||
|
||||
assert isinstance(contents[0], TextContent)
|
||||
assert contents[0].text == '{"key": "value", "number": 42}'
|
||||
assert contents[0].additional_properties == {"source": "test"}
|
||||
|
||||
|
||||
def test_a2a_parts_to_contents_unknown_part_kind() -> None:
|
||||
"""Test error handling for unknown A2A part kind."""
|
||||
agent = A2AAgent.model_construct(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create a mock part with unknown kind
|
||||
mock_part = MagicMock()
|
||||
mock_part.root.kind = "unknown_kind"
|
||||
|
||||
with raises(ValueError, match="Unknown Part kind: unknown_kind"):
|
||||
agent._a2a_parts_to_contents([mock_part])
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_hosted_file() -> None:
|
||||
"""Test conversion of ChatMessage with HostedFileContent to A2A message."""
|
||||
|
||||
agent = A2AAgent.model_construct(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create message with hosted file content
|
||||
message = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[HostedFileContent(file_id="hosted://storage/document.pdf")],
|
||||
)
|
||||
|
||||
result = agent._chat_message_to_a2a_message(message) # noqa: SLF001
|
||||
|
||||
# Verify the conversion
|
||||
assert len(result.parts) == 1
|
||||
part = result.parts[0]
|
||||
assert part.root.kind == "file"
|
||||
|
||||
# Verify it's a FilePart with FileWithUri
|
||||
|
||||
assert isinstance(part.root, FilePart)
|
||||
assert isinstance(part.root.file, FileWithUri)
|
||||
assert part.root.file.uri == "hosted://storage/document.pdf"
|
||||
assert part.root.file.mime_type is None # HostedFileContent doesn't specify media_type
|
||||
|
||||
|
||||
def test_a2a_parts_to_contents_with_hosted_file_uri() -> None:
|
||||
"""Test conversion of A2A FilePart with hosted file URI back to UriContent."""
|
||||
|
||||
agent = A2AAgent.model_construct(client=MagicMock(), _http_client=None)
|
||||
|
||||
# Create FilePart with hosted file URI (simulating what A2A would send back)
|
||||
file_part = Part(
|
||||
root=FilePart(
|
||||
file=FileWithUri(
|
||||
uri="hosted://storage/document.pdf",
|
||||
mime_type=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
contents = agent._a2a_parts_to_contents([file_part]) # noqa: SLF001
|
||||
|
||||
assert len(contents) == 1
|
||||
|
||||
assert isinstance(contents[0], UriContent)
|
||||
assert contents[0].uri == "hosted://storage/document.pdf"
|
||||
assert contents[0].media_type == "" # Converted None to empty string
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
PACKAGE_NAME = "agent_framework_a2a"
|
||||
PACKAGE_EXTRA = "a2a"
|
||||
_IMPORTS = ["__version__", "A2AAgent"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(PACKAGE_NAME), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_EXTRA}' extra is not installed, "
|
||||
f"please do `pip install agent-framework[{PACKAGE_EXTRA}]`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_a2a import A2AAgent, __version__
|
||||
|
||||
__all__ = ["A2AAgent", "__version__"]
|
||||
@@ -35,7 +35,7 @@ dependencies = [
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.36.0",
|
||||
"opentelemetry-semantic-conventions-ai>=0.4.13",
|
||||
"aiofiles>=24.1.0",
|
||||
"azure-identity>=1,<2"
|
||||
"azure-identity>=1,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -63,6 +63,9 @@ runtime = [
|
||||
mem0 = [
|
||||
"agent-framework-mem0"
|
||||
]
|
||||
a2a = [
|
||||
"agent-framework-a2a"
|
||||
]
|
||||
devui = [
|
||||
"agent-framework-devui"
|
||||
]
|
||||
@@ -71,6 +74,7 @@ all = [
|
||||
"agent-framework-azure-ai",
|
||||
"agent-framework-runtime",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-a2a",
|
||||
"agent-framework-redis",
|
||||
"agent-framework-devui",
|
||||
"graphviz>=0.20.0"
|
||||
@@ -91,6 +95,7 @@ fallback-version = "0.0.0"
|
||||
testpaths = [
|
||||
'tests',
|
||||
'packages/main/tests',
|
||||
'packages/a2a/tests',
|
||||
'packages/azure-ai/tests',
|
||||
'packages/copilotstudio/tests',
|
||||
'packages/mem0/tests',
|
||||
|
||||
@@ -5,6 +5,7 @@ version = "0.0.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"agent-framework",
|
||||
"agent-framework-a2a",
|
||||
"agent-framework-azure-ai",
|
||||
"agent-framework-copilotstudio",
|
||||
"agent-framework-mem0",
|
||||
@@ -54,6 +55,7 @@ exclude = [ "packages/agent_framework_project.egg-info" ]
|
||||
|
||||
[tool.uv.sources]
|
||||
agent-framework = { workspace = true }
|
||||
agent-framework-a2a = { workspace = true }
|
||||
agent-framework-azure-ai = { workspace = true }
|
||||
agent-framework-copilotstudio = { workspace = true }
|
||||
agent-framework-lab = { workspace = true }
|
||||
@@ -194,6 +196,7 @@ pre-commit-check = ["fmt", "lint", "pyright", "markdown-code-lint", "samples-cod
|
||||
cmd = """
|
||||
pytest --import-mode=importlib
|
||||
--cov=agent_framework
|
||||
--cov=agent_framework_a2a
|
||||
--cov=agent_framework_azure_ai
|
||||
--cov=agent_framework_copilotstudio
|
||||
--cov=agent_framework_mem0
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# A2A Agent Examples
|
||||
|
||||
This folder contains examples demonstrating how to create and use agents with the A2A (Agent-to-Agent) protocol from the `agent_framework` package to communicate with remote A2A agents.
|
||||
|
||||
For more information about the A2A protocol specification, visit: https://a2a-protocol.org/latest/
|
||||
## Examples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`agent_with_a2a.py`](agent_with_a2a.py) | The simplest way to connect to and use a single A2A agent. Demonstrates agent discovery via agent cards and basic message exchange using the A2A protocol. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Make sure to set the following environment variables before running the example:
|
||||
|
||||
### Required
|
||||
- `A2A_AGENT_HOST`: URL of a single A2A agent (for simple sample, e.g., `http://localhost:5001/`)
|
||||
|
||||
|
||||
## Quick Testing with .NET A2A Servers
|
||||
|
||||
For quick testing and demonstration, you can use the pre-built .NET A2A servers from this repository:
|
||||
|
||||
**Quick Testing Reference**: Use the .NET A2A Client Server sample at:
|
||||
`..\agent-framework\dotnet\samples\A2AClientServer`
|
||||
|
||||
### Run Python A2A Sample
|
||||
```powershell
|
||||
# Simple A2A sample (single agent)
|
||||
uv run python agent_with_a2a.py
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from a2a.client import A2ACardResolver
|
||||
from agent_framework.a2a import A2AAgent
|
||||
|
||||
"""
|
||||
Agent-to-Agent (A2A) Protocol Integration Sample
|
||||
|
||||
This sample demonstrates how to connect to and communicate with external agents using
|
||||
the A2A protocol. A2A is a standardized communication protocol that enables interoperability
|
||||
between different agent systems, allowing agents built with different frameworks and
|
||||
technologies to communicate seamlessly.
|
||||
|
||||
For more information about the A2A protocol specification, visit: https://a2a-protocol.org/latest/
|
||||
|
||||
Key concepts demonstrated:
|
||||
- Discovering A2A-compliant agents using AgentCard resolution
|
||||
- Creating A2AAgent instances to wrap external A2A endpoints
|
||||
- Converting Agent Framework messages to A2A protocol format
|
||||
- Handling A2A responses (Messages and Tasks) back to framework types
|
||||
|
||||
To run this sample:
|
||||
1. Set the A2A_AGENT_HOST environment variable to point to an A2A-compliant agent endpoint
|
||||
Example: export A2A_AGENT_HOST="https://your-a2a-agent.example.com"
|
||||
2. Ensure the target agent exposes its AgentCard at /.well-known/agent.json
|
||||
3. Run: uv run python agent_with_a2a.py
|
||||
|
||||
The sample will:
|
||||
- Connect to the specified A2A agent endpoint
|
||||
- Retrieve and parse the agent's capabilities via its AgentCard
|
||||
- Send a message using the A2A protocol
|
||||
- Display the agent's response
|
||||
|
||||
Visit the README.md for more details on setting up and running A2A agents.
|
||||
"""
|
||||
|
||||
|
||||
async def main():
|
||||
"""Demonstrates connecting to and communicating with an A2A-compliant agent."""
|
||||
# Get A2A agent host from environment
|
||||
a2a_agent_host = os.getenv("A2A_AGENT_HOST")
|
||||
if not a2a_agent_host:
|
||||
raise ValueError("A2A_AGENT_HOST environment variable is not set")
|
||||
|
||||
print(f"Connecting to A2A agent at: {a2a_agent_host}")
|
||||
|
||||
# Initialize A2ACardResolver
|
||||
async with httpx.AsyncClient(timeout=60.0) as http_client:
|
||||
resolver = A2ACardResolver(httpx_client=http_client, base_url=a2a_agent_host)
|
||||
|
||||
# Get agent card
|
||||
agent_card = await resolver.get_agent_card(relative_card_path="/.well-known/agent.json")
|
||||
print(f"Found agent: {agent_card.name} - {agent_card.description}")
|
||||
|
||||
# Create A2A agent instance
|
||||
agent = A2AAgent(
|
||||
name=agent_card.name, description=agent_card.description, agent_card=agent_card, url=a2a_agent_host
|
||||
)
|
||||
|
||||
# Invoke the agent and output the result
|
||||
print("\nSending message to A2A agent...")
|
||||
response = await agent.run("Tell me a joke about a pirate.")
|
||||
|
||||
# Print the response
|
||||
print("\nAgent Response:")
|
||||
for message in response.messages:
|
||||
print(message.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Generated
+125
-1
@@ -24,6 +24,7 @@ supported-markers = [
|
||||
[manifest]
|
||||
members = [
|
||||
"agent-framework",
|
||||
"agent-framework-a2a",
|
||||
"agent-framework-azure-ai",
|
||||
"agent-framework-copilotstudio",
|
||||
"agent-framework-devui",
|
||||
@@ -34,6 +35,22 @@ members = [
|
||||
"agent-framework-runtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "a2a-sdk"
|
||||
version = "0.3.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/ad/b6ecb58f44459a24f1c260e91304e1ddbb7a8e213f1f82cc4c074f66e9bb/a2a_sdk-0.3.7.tar.gz", hash = "sha256:795aa2bd2cfb3c9e8654a1352bf5f75d6cf1205b262b1bf8f4003b5308267ea2", size = 223426, upload-time = "2025-09-23T16:27:29.585Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/27/9cf8c6de4ae71e9c98ec96b3304449d5d0cd36ec3b95e66b6e7f58a9e571/a2a_sdk-0.3.7-py3-none-any.whl", hash = "sha256:0813b8fd7add427b2b56895cf28cae705303cf6d671b305c0aac69987816e03e", size = 137957, upload-time = "2025-09-23T16:27:27.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "addict"
|
||||
version = "2.4.0"
|
||||
@@ -64,7 +81,11 @@ dependencies = [
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
a2a = [
|
||||
{ name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
all = [
|
||||
{ name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -103,6 +124,8 @@ viz = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-a2a", marker = "extra == 'a2a'", editable = "packages/a2a" },
|
||||
{ name = "agent-framework-a2a", marker = "extra == 'all'", editable = "packages/a2a" },
|
||||
{ name = "agent-framework-azure-ai", marker = "extra == 'all'", editable = "packages/azure-ai" },
|
||||
{ name = "agent-framework-azure-ai", marker = "extra == 'azure'", editable = "packages/azure-ai" },
|
||||
{ name = "agent-framework-azure-ai", marker = "extra == 'azure-ai'", editable = "packages/azure-ai" },
|
||||
@@ -133,7 +156,22 @@ requires-dist = [
|
||||
{ name = "pydantic-settings", specifier = ">=2,<3" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
provides-extras = ["azure-ai", "azure", "microsoft-copilotstudio", "microsoft", "redis", "viz", "runtime", "mem0", "devui", "all"]
|
||||
provides-extras = ["azure-ai", "azure", "microsoft-copilotstudio", "microsoft", "redis", "viz", "runtime", "mem0", "a2a", "devui", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-a2a"
|
||||
version = "0.1.0b1"
|
||||
source = { editable = "packages/a2a" }
|
||||
dependencies = [
|
||||
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "a2a-sdk", specifier = ">=0.3.5" },
|
||||
{ name = "agent-framework", editable = "packages/main" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azure-ai"
|
||||
@@ -269,6 +307,7 @@ version = "0.0.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -304,6 +343,7 @@ docs = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework", editable = "packages/main" },
|
||||
{ name = "agent-framework-a2a", editable = "packages/a2a" },
|
||||
{ name = "agent-framework-azure-ai", editable = "packages/azure-ai" },
|
||||
{ name = "agent-framework-copilotstudio", editable = "packages/copilotstudio" },
|
||||
{ name = "agent-framework-devui", editable = "packages/devui" },
|
||||
@@ -712,6 +752,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "5.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.8.3"
|
||||
@@ -1596,6 +1645,36 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "proto-plus", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443, upload-time = "2025-06-12T20:52:20.439Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807, upload-time = "2025-06-12T20:52:19.334Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.40.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pyasn1-modules", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "rsa", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029, upload-time = "2025-06-04T18:04:57.577Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.70.0"
|
||||
@@ -3707,6 +3786,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proto-plus"
|
||||
version = "1.26.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "5.29.5"
|
||||
@@ -3750,6 +3841,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/bc/056b4751229c92b58296f8eccd262a448f799b3044510f2357742eeeae98/py2docfx-0.1.21-py3-none-any.whl", hash = "sha256:ce782d503593c79fc49324d3b102fe38b1cf5de9e13dfe582abe6d1387a67999", size = 11410660, upload-time = "2025-09-23T03:44:18.458Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
@@ -4503,6 +4615,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.13.2"
|
||||
|
||||
Reference in New Issue
Block a user