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',
|
||||
|
||||
Reference in New Issue
Block a user