From 0d55b6eebd8c33d27115243a49a9708bb737ea29 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 09:31:19 -0700 Subject: [PATCH 01/39] First commit for a2a work --- .../microsoft_agents/activity/channels.py | 25 ++------ .../microsoft-agents-hosting-a2a/LICENSE | 21 ++++++ .../microsoft-agents-hosting-a2a/MANIFEST.in | 1 + .../microsoft_agents/hosting/a2a/__init__.py | 0 .../hosting/a2a/a2a_activity.py | 64 +++++++++++++++++++ .../hosting/a2a/a2a_adapter.py | 0 .../microsoft_agents/hosting/a2a/constants.py | 0 .../microsoft_agents/hosting/a2a/py.typed | 0 .../pyproject.toml | 28 ++++++++ .../microsoft-agents-hosting-a2a/readme.md | 0 .../microsoft-agents-hosting-a2a/setup.py | 18 ++++++ 11 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-a2a/LICENSE create mode 100644 libraries/microsoft-agents-hosting-a2a/MANIFEST.in create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_activity.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/py.typed create mode 100644 libraries/microsoft-agents-hosting-a2a/pyproject.toml create mode 100644 libraries/microsoft-agents-hosting-a2a/readme.md create mode 100644 libraries/microsoft-agents-hosting-a2a/setup.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index f94e7fe12..dea213eea 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -10,65 +10,50 @@ class Channels(str, Enum): Ids of channels supported by ABS. """ - """Agents channel.""" +` # A2A protocol + a2a = "a2a" + agents = "agents" console = "console" - """Console channel.""" cortana = "cortana" - """Cortana channel.""" direct_line = "directline" - """Direct Line channel.""" direct_line_speech = "directlinespeech" - """Direct Line Speech channel.""" email = "email" - """Email channel.""" emulator = "emulator" - """Emulator channel.""" facebook = "facebook" - """Facebook channel.""" groupme = "groupme" - """Group Me channel.""" kik = "kik" - """Kik channel.""" line = "line" - """Line channel.""" ms_teams = "msteams" - """MS Teams channel.""" skype = "skype" - """Skype channel.""" skype_for_business = "skypeforbusiness" - """Skype for Business channel.""" slack = "slack" - """Slack channel.""" + # SMS (Twilio) sms = "sms" - """SMS (Twilio) channel.""" telegram = "telegram" - """Telegram channel.""" test = "test" - """Test channel.""" webchat = "webchat" - """WebChat channel.""" + # Microsoft Copilot Studio channel copilot_studio = "pva-studio" - """Microsoft Copilot Studio channel.""" # TODO: validate the need of Self annotations in the following methods @staticmethod diff --git a/libraries/microsoft-agents-hosting-a2a/LICENSE b/libraries/microsoft-agents-hosting-a2a/LICENSE new file mode 100644 index 000000000..9e841e7a2 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/LICENSE @@ -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 diff --git a/libraries/microsoft-agents-hosting-a2a/MANIFEST.in b/libraries/microsoft-agents-hosting-a2a/MANIFEST.in new file mode 100644 index 000000000..43a71d9ed --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/MANIFEST.in @@ -0,0 +1 @@ +include VERSION.txt \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_activity.py new file mode 100644 index 000000000..a03e79323 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_activity.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams-aware :class:`Activity` subclass exposing Teams channel data helpers.""" + +from __future__ import annotations + +from typing import Literal +from uuid import uuid4 + +from a2a.types import ( + Part, + Task, + TaskState, + Message +) + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + Channels, + ChannelAccount, + RoleTypes, +) + +_DEFAULT_USER_ID = "unknown" +_ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" + +class A2AActivity(Activity): + """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" + + @staticmethod + def activity_from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: + + if not task_id and not message.task_id: + raise ValueError("T") + + task_id = task_id or message.task_id + + activity = A2AActivity._create_activity( + task_id, + message.parts, + True, + True + ) + + @staticmethod + def _create_activity( + conversation_id: str, + parts: list[Part], + is_ingress: bool, + is_streaming: bool + ) -> A2AActivity: + """Create an Activity representing an A2A concept.""" + + agent = ChannelAccount(id="assistant", role=RoleTypes.agent) + user = ChannelAccount(id=_DEFAULT_USER_ID, role=RoleTypes.user) + + activity = A2AActivity( + type=ActivityTypes.message, + id=str(uuid4()), + channel_id = Channels.a2a, + + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/py.typed b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-a2a/pyproject.toml b/libraries/microsoft-agents-hosting-a2a/pyproject.toml new file mode 100644 index 000000000..d1132cb97 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-hosting-a2a" +dynamic = ["version", "dependencies"] +description = "Library adding A2A handling to an SDK Agent" +readme = {file = "readme.md", content-type = "text/markdown"} +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", +] + +[tool.setuptools.package-data] +"microsoft_agents.hosting.a2a" = ["py.typed"] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" diff --git a/libraries/microsoft-agents-hosting-a2a/readme.md b/libraries/microsoft-agents-hosting-a2a/readme.md new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-a2a/setup.py b/libraries/microsoft-agents-hosting-a2a/setup.py new file mode 100644 index 000000000..c24af4dca --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/setup.py @@ -0,0 +1,18 @@ +from os import environ, path +from setuptools import setup + +# Try to read from VERSION.txt file first, fall back to environment variable +version_file = path.join(path.dirname(__file__), "VERSION.txt") +if path.exists(version_file): + with open(version_file, "r", encoding="utf-8") as f: + package_version = f.read().strip() +else: + package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + f"microsoft-agents-hosting-core=={package_version}", + "a2a-sdk>=1.0.0", + ], +) From 371542e87ef20e5c2528e600cc2134398e6ac089 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 13:04:50 -0700 Subject: [PATCH 02/39] More updates --- .../microsoft_agents/activity/channels.py | 26 +++++- .../microsoft_agents/hosting/a2a/__init__.py | 10 +++ .../hosting/a2a/a2a_activity.py | 64 -------------- .../hosting/a2a/a2a_turn_context.py | 57 +++++++++++++ .../hosting/a2a/activity/__init__.py | 5 ++ .../hosting/a2a/activity/a2a_activity.py | 64 ++++++++++++++ .../hosting/a2a/route_handlers.py | 85 +++++++++++++++++++ .../microsoft_agents/hosting/a2a/type_defs.py | 37 ++++++++ 8 files changed, 280 insertions(+), 68 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_activity.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/type_defs.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index dea213eea..168bf4808 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -10,50 +10,68 @@ class Channels(str, Enum): Ids of channels supported by ABS. """ -` # A2A protocol a2a = "a2a" + """A2A protocol""" + """Agents channel.""" agents = "agents" console = "console" + """Console channel.""" cortana = "cortana" + """Cortana channel.""" direct_line = "directline" + """Direct Line channel.""" direct_line_speech = "directlinespeech" + """Direct Line Speech channel.""" email = "email" + """Email channel.""" emulator = "emulator" + """Emulator channel.""" facebook = "facebook" + """Facebook channel.""" groupme = "groupme" + """Group Me channel.""" kik = "kik" + """Kik channel.""" line = "line" + """Line channel.""" ms_teams = "msteams" + """MS Teams channel.""" skype = "skype" + """Skype channel.""" skype_for_business = "skypeforbusiness" + """Skype for Business channel.""" slack = "slack" + """Slack channel.""" - # SMS (Twilio) sms = "sms" + """SMS (Twilio) channel.""" telegram = "telegram" + """Telegram channel.""" test = "test" + """Test channel.""" webchat = "webchat" + """WebChat channel.""" - # Microsoft Copilot Studio channel copilot_studio = "pva-studio" + """Microsoft Copilot Studio channel.""" # TODO: validate the need of Self annotations in the following methods @staticmethod @@ -146,4 +164,4 @@ def max_action_title_length( # pylint: disable=unused-argument int: The total number of characters allowed for an Action Title on a specific Channel. """ - return 20 + return 20 \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py index e69de29bb..cd66ce5e9 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .activity import A2AActivity +from .a2a_turn_context import A2ATurnContext + +__all__ = [ + "A2AActivity", + "A2ATurnContext", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_activity.py deleted file mode 100644 index a03e79323..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_activity.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Teams-aware :class:`Activity` subclass exposing Teams channel data helpers.""" - -from __future__ import annotations - -from typing import Literal -from uuid import uuid4 - -from a2a.types import ( - Part, - Task, - TaskState, - Message -) - -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - Channels, - ChannelAccount, - RoleTypes, -) - -_DEFAULT_USER_ID = "unknown" -_ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" - -class A2AActivity(Activity): - """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" - - @staticmethod - def activity_from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: - - if not task_id and not message.task_id: - raise ValueError("T") - - task_id = task_id or message.task_id - - activity = A2AActivity._create_activity( - task_id, - message.parts, - True, - True - ) - - @staticmethod - def _create_activity( - conversation_id: str, - parts: list[Part], - is_ingress: bool, - is_streaming: bool - ) -> A2AActivity: - """Create an Activity representing an A2A concept.""" - - agent = ChannelAccount(id="assistant", role=RoleTypes.agent) - user = ChannelAccount(id=_DEFAULT_USER_ID, role=RoleTypes.user) - - activity = A2AActivity( - type=ActivityTypes.message, - id=str(uuid4()), - channel_id = Channels.a2a, - - ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py new file mode 100644 index 000000000..544d62a95 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams-specific turn context wrapper.""" + +from __future__ import annotations + +from typing import cast + +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, +) + +from .activity import A2AActivity + + +class A2ATurnContext(TurnContext): + """A context object for handling A2A-specific turn functionality. + + Wraps a plain :class:`TurnContext` so that Teams-aware route handlers + receive a typed context without changing the core routing engine. + """ + + def __init__(self, context: TurnContext, app: AgentApplication) -> None: + """Initialise the Teams turn context from a plain turn context. + + :param context: The base turn context provided by the core runtime. + :param app: The agent application that is handling the turn. + """ + super().__init__(context) + self._app = app + self._turn_state = context.turn_state + + self._original = context + + self._set_a2a_activity() + + def _set_a2a_activity(self) -> None: + self._activity.__class__ = A2AActivity + self._a2a_activity = cast(A2AActivity, self._activity) + + @property + def responded(self) -> bool: + return self._original.responded + + @responded.setter + def responded(self, value: bool): + self._original.responded = value + + @property + def streaming_response(self): + return self._original.streaming_response + + @property + def activity(self) -> A2AActivity: + return self._a2a_activity \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py new file mode 100644 index 000000000..4e4b617a8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py @@ -0,0 +1,5 @@ +from .a2a_activity import A2AActivity + +__all__ = [ + "A2AActivity" +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py new file mode 100644 index 000000000..dfe52952e --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams-aware :class:`Activity` subclass exposing Teams channel data helpers.""" + +from __future__ import annotations + +from typing import Literal +from uuid import uuid4 + +from a2a.types import ( + Part, + Task, + TaskState, + Message +) + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + Channels, + ChannelAccount, + RoleTypes, +) + +_DEFAULT_USER_ID = "unknown" +_ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" + +class A2AActivity(Activity): + """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" + + # @staticmethod + # def activity_from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: + + # if not task_id and not message.task_id: + # raise ValueError("T") + + # task_id = task_id or message.task_id + + # activity = A2AActivity._create_activity( + # task_id, + # message.parts, + # True, + # True + # ) + + # @staticmethod + # def _create_activity( + # conversation_id: str, + # parts: list[Part], + # is_ingress: bool, + # is_streaming: bool + # ) -> A2AActivity: + # """Create an Activity representing an A2A concept.""" + + # agent = ChannelAccount(id="assistant", role=RoleTypes.agent) + # user = ChannelAccount(id=_DEFAULT_USER_ID, role=RoleTypes.user) + + # activity = A2AActivity( + # type=ActivityTypes.message, + # id=str(uuid4()), + # channel_id = Channels.a2a, + + # ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py new file mode 100644 index 000000000..b7625ca1e --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definitions for Teams-aware route and handoff handlers.""" + +from __future__ import annotations + +from typing import ( + Awaitable, + Protocol, +) + +from microsoft_agents.hosting.core import AgentApplication, TurnContext +from microsoft_agents.hosting.core.app._type_defs import RouteHandler + +from .a2a_turn_context import A2ATurnContext +from .type_defs import _StateContra + + +class A2ARouteHandler(Protocol[_StateContra]): + """Protocol for a Teams route handler that receives a :class:`TeamsTurnContext`.""" + + def __call__( + self, context: TeamsTurnContext, state: _StateContra, / + ) -> Awaitable[None]: + """Handle a turn with Teams context. + + :param context: Teams-aware turn context. + :param state: The current turn state. + """ + ... + + +def wrap_a2a_route_handler( + handler: A2ARouteHandler[_StateContra], app: AgentApplication +) -> RouteHandler[_StateContra]: + """Adapt a :class:`TeamsRouteHandler` into a plain :class:`RouteHandler`. + + Wraps *handler* so that the core routing engine (which passes a plain + :class:`TurnContext`) receives a compatible callable. + + :param handler: The Teams-specific handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`RouteHandler` that upgrades the context before delegating. + """ + + async def __func(context: TurnContext, state: _StateContra) -> None: + a2a_context = A2ATurnContext(context, app) + await handler(a2a_context, state) + + return __func + + +class TeamsHandoffHandler(Protocol[_StateContra]): + """Protocol for a Teams handoff handler that receives handoff continuation data.""" + + def __call__( + self, context: TeamsTurnContext, state: _StateContra, handoff_data: str, / + ) -> Awaitable[None]: + """Handle a handoff activity with Teams context. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param handoff_data: Opaque continuation data from the handoff initiator. + """ + ... + + +def wrap_a2a_handler( + handler: TeamsHandoffHandler[_StateContra], app: AgentApplication +) -> HandoffHandler[_StateContra]: + """Adapt a :class:`TeamsHandoffHandler` into a plain :class:`HandoffHandler`. + + :param handler: The Teams-specific handoff handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`HandoffHandler` that upgrades the context before delegating. + """ + + async def __func( + context: TurnContext, state: _StateContra, handoff_data: str + ) -> None: + teams_context = TeamsTurnContext(context, app) + await handler(teams_context, state, handoff_data) + + return __func diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/type_defs.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/type_defs.py new file mode 100644 index 000000000..bfb29db7c --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/type_defs.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Shared type aliases and protocols used across the Teams hosting sub-package.""" + +from typing import ( + Callable, + TYPE_CHECKING, + TypeVar, + Pattern, + Protocol, +) + +from microsoft_agents.hosting.core import TurnState + +if TYPE_CHECKING: + from .a2a_turn_context import A2ATurnContext + +TeamsRouteSelector = Callable[["A2ATurnContext"], bool] + +StateT = TypeVar("StateT", bound=TurnState) +_StateContra = TypeVar("_StateContra", bound=TurnState, contravariant=True) +RouteHandlerT = TypeVar("RouteHandlerT", bound=Callable) + +CommandSelector = str | Pattern[str] | None + + +class _RouteDecorator(Protocol[RouteHandlerT]): + """Protocol for a decorator that registers *func* as a route and returns it unchanged.""" + + def __call__(self, func: RouteHandlerT) -> RouteHandlerT: + """Register *func* as a route handler and return it. + + :param func: The handler to register. + :return: The same handler, unmodified, so it can be used as a plain callable. + """ + ... From b546e6bfb32140e1be047d77f80e58fecabd2f14 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 17 Sep 2026 13:00:25 -0700 Subject: [PATCH 03/39] Progress on A2A --- .../microsoft_agents/activity/activity.py | 1 + .../hosting/a2a/a2a_adapter.py | 129 +++++++++++++++ .../hosting/a2a/activity/a2a_activity.py | 109 +++---------- .../hosting/a2a/activity/utils.py | 115 ++++++++++++++ .../hosting/a2a/adapter/__init__.py | 0 .../hosting/a2a/adapter/a2a_adapter.py | 149 ++++++++++++++++++ .../hosting/a2a/adapter/a2a_cloud_adapter.py | 32 ++++ .../a2a/adapter/agent_request_context.py | 11 ++ .../hosting/a2a/agent_request_context.py | 52 ++++++ .../hosting/a2a/protocol/__init__.py | 0 .../hosting/a2a/protocol/http.py | 6 + .../hosting/a2a/protocol/json_rpc.py | 0 .../hosting/a2a/request_handlers/__init__.py | 7 + .../request_handlers/a2a_agent_executor.py | 56 +++++++ .../request_handlers/a2a_request_handler.py | 23 +++ .../microsoft-agents-hosting-a2a/setup.py | 1 + .../hosting/core/channel_adapter.py | 2 +- .../hosting/core/channel_adapter_protocol.py | 81 ++++++++++ .../hosting/fastapi/cloud_adapter.py | 79 ++++++++++ 19 files changed, 762 insertions(+), 91 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/agent_request_context.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/http.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/json_rpc.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter_protocol.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 54b49fab0..6be3757e3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -199,6 +199,7 @@ class Activity(AgentsModel): text_highlights: list[TextHighlight] = None semantic_action: SemanticAction = None caller_id: NonEmptyString = None + request_id: str | None @field_validator("entities", mode="before") @classmethod diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index e69de29bb..c63cb04c3 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +from fastapi import Request, Response + +from a2a.server.agent_execution import RequestContext, SimpleRequestContextBuilder +from a2a.server.events.event_queue_v2 import EventQueue + + +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentInterface, + AgentSkill, + HTTPAuthSecurityScheme, + SecurityScheme, +) +from a2a.utils.constants import TransportProtocol + +from google.protobuf import json_format + +from microsoft_agents.hosting.core import ( + Agent, + ChannelServiceAdapter, + ClaimsIdentity, +) + +from microsoft_agents.hosting.fastapi import CloudAdapter + +from .activity import A2AActivity + +logger = logging.getLogger(__file__) + +class A2AAdapter(): + + def __init__( + self, + adapter: CloudAdapter, + *, + agent_card_name: str | None = None, + agent_card_description: str | None = None, + agent_card_version: str | None = None, + ): + self._inner = adapter + + self._agent_card_name = agent_card_name or self.__class__.__name__ + self._agent_card_description = agent_card_description or "Agents SDK A2A" + self._agent_card_version = agent_card_version or "0.0.0" + + async def process(self, request: Request, agent: Agent) -> Response | None: + return await self.process_json_rpc(request, agent) + + # async def process_json_rpc(self, request: Request, agent: Agent) -> Response | None: + # return await A2AJsonRpcProcessor.process_request( + + # ) + + async def process_agent_card(self, request: Request, agent: Agent, path_prefix: str) -> Response: + agent_card = AgentCard( + name=self._agent_card_name, + description=self._agent_card_description, + version=self._agent_card_version, + security_schemes={ + "jwt": SecurityScheme(http_auth_security_scheme=HTTPAuthSecurityScheme(scheme="bearer")) + }, + default_input_modes=["application/json"], + default_output_modes=["application/json"], + skills=[], + capabilities=AgentCapabilities( + extended_agent_card=True, + streaming=True, + ), + supported_interfaces=[], + ) + + agent_interfaces = [] + if not agent_interfaces: + agent_card.supported_interfaces.append( + AgentInterface( + protocol_binding=TransportProtocol.JSONRPC, + url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", + protocol_version="1.0", + ) + ) + else: + for agent_interface in agent_interfaces: + if agent_interface.protocol in (TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON): + agent_card.supported_interfaces.append( + protocol_binding=agent_interface.protocol, + url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", + protocol_version="1.0", + ) + else: + logger.info("Unsupported protocol: %s", agent_interface.protocol) + + skills = [] + if skills: + for skill_info in skills: + agent_card.skills.append(AgentSkill( + id=skill_info.id, + name=skill_info.name, + description=skill_info.description, + tags=skill_info.tags, + examples=skill_info.examples, + input_modes=skill_info.input_modes, + output_modes=skill_info.output_modes, + )) + + # agent card handler scenario here from .NET... + + logger.debug("Agent card: %s", agent_card) + + return Response( + content=json_format.MessageToJson(agent_card), + media_type="application/json", + ) + + def _create_agent_request_context(self, request: Request, agent: Agent, cache: bool = True) -> RequestContext: + ... + + async def execute_agent_turn(self, request_id: str, identity: ClaimsIdentity, agent: Agent, context: RequestContext, event_queue: EventQueue) -> None: + + if not context.message: + raise ValueError("Context must have a message.") + + activity = A2AActivity.activity_from_message(request_id, context.task_id, context.message) + \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index eeb99b3a8..dee4970e5 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Literal, Mapping +from typing import Literal, Mapping, Iterable from uuid import uuid4 from a2a.types import ( @@ -29,22 +29,22 @@ StreamInfo, ) +from . import utils + _DEFAULT_USER_ID = "unknown" _ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" -_SCHEMAS: dict[str, Mapping] = {} - class A2AActivity(Activity): """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" @staticmethod - def activity_from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: + def from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: if not task_id and not message.task_id: - raise ValueError("T") - - task_id = task_id or message.task_id + raise ValueError("Task ID is required.") + task_id = task_id or message.task_id + activity = A2AActivity._create_activity( task_id, message.parts, @@ -52,100 +52,29 @@ def activity_from_message(request_id: str, task_id: str | None, message: Message True ) - def create_message(self, context_id: str, task_id: str, activity: Activity, include_entities: bool = True) -> Message: - artifact = self.create_artifact(include_entities=include_entities) - return Message( - task_id=task_id, - context_id=context_id, - message_id=str(uuid4()), - parts=artifact.parts, - role=RoleTypes.agent, - ) + activity.request_id = request_id or str(uuid4()) # formatting + activity.channel_data = message + message.context_id = message.context_id or str(uuid4()) # Formatting + message.task_id = task_id - def create_artifact(self, artifact_id: str | None = None, include_entities: bool = True) -> Artifact: + return activity - artifact = Artifact( - artifact_id=artifact_id if artifact_id else str(uuid4()), - ) + def to_message(self, context_id: str, task_id: str, include_entities: bool = True) -> Message: + return utils.activity_to_message(context_id, task_id, self, include_entities) - if self.text is not None: - artifact.parts.append(Part(text=self.text)) - - if self.value is not None and isinstance(self.value, dict): - artifact.parts.append(Part(data=self.value)) - - for attachment in self.attachments: - if attachment.content_url and not isinstance(attachment.content, str): - continue - - part: Part - if attachment.content_url: - part = Part( - url=attachment.content_url, - media_type=attachment.content_type, - filename=attachment.name, - ) - elif isinstance(attachment.content, dict): - part = Part( - data=attachment.content, - media_type=attachment.content_type, - ) - else: - raise RuntimeError("Unsupported attachment content type.") - - artifact.parts.append(part) - - if include_entities: - for entity in self.entities: - if not isinstance(entity, StreamInfo): - - if entity.type not in _SCHEMAS: - _SCHEMAS[entity.type] = _to_a2a_metadata(entity, _ENTITY_TYPE_TEMPLATE.format(entity.type)) - - cached_metadata = _SCHEMAS[entity.type] - - artifact.parts.append(Part( - metadata=cached_metadata, - data=entity.model_dump(exclude_none=True), - )) - - return artifact - - @staticmethod - def create_artifact_from_data( - data: dict, - name: str | None = None, - description: str | None = None, - media_type: str | None = None, - artifact_id: str | None = None, - ) -> Artifact | None: - if data is None: - return None - - return Artifact( - artifact_id=artifact_id if artifact_id else str(uuid4()), # check .NET - name=name, - description=description, - parts=[ - Part( - data=data, - metadata=_get_a2a_metadata(data, media_type or data.__class__.__name__) - ) - ] - ) + def to_artifact(self, artifact_id: str | None = None, include_entities: bool = True) -> Artifact: + return utils.activity_to_artifact(self, artifact_id, include_entities) def has_message_content(self) -> bool: - return bool(self.text) or bool(self.attachments) + return utils.has_message_content(self) def get_task_state(self) -> TaskState: - if self.input_hint == InputHints.expecting_input: - return TaskState.TASK_STATE_INPUT_REQUIRED - return TaskState.TASK_STATE_WORKING + return utils.get_task_state(self) @staticmethod def _create_activity( conversation_id: str, - parts: list[Part], + parts: Iterable[Part], is_ingress: bool, is_streaming: bool ) -> A2AActivity: diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py new file mode 100644 index 000000000..2e62afd56 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -0,0 +1,115 @@ +from typing import Mapping, cast +from uuid import uuid4 + +from a2a.types import ( + Artifact, + Part, + Message, + TaskState, +) + +from microsoft_agents.activity import ( + Activity, + InputHints, + RoleTypes, + StreamInfo, +) +from microsoft_agents.hosting.core import TurnContext + +_ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" +_SCHEMAS: dict[str, Mapping] = {} + +def activity_to_artifact(activity: Activity, artifact_id: str | None = None, include_entities: bool = True) -> Artifact: + + artifact = Artifact( + artifact_id=artifact_id if artifact_id else str(uuid4()), + ) + + if activity.text is not None: + artifact.parts.append(Part(text=activity.text)) + + if activity.value is not None and isinstance(activity.value, dict): + artifact.parts.append(Part(data=activity.value)) + + for attachment in activity.attachments: + if attachment.content_url and not isinstance(attachment.content, str): + continue + + part: Part + if attachment.content_url: + part = Part( + url=attachment.content_url, + media_type=attachment.content_type, + filename=attachment.name, + ) + elif isinstance(attachment.content, dict): + part = Part( + data=attachment.content, + media_type=attachment.content_type, + ) + else: + raise RuntimeError("Unsupported attachment content type.") + + artifact.parts.append(part) + + if include_entities: + for entity in self.entities: + if not isinstance(entity, StreamInfo): + + if entity.type not in _SCHEMAS: + _SCHEMAS[entity.type] = _to_a2a_metadata(entity, _ENTITY_TYPE_TEMPLATE.format(entity.type)) + + cached_metadata = _SCHEMAS[entity.type] + + artifact.parts.append(Part( + metadata=cached_metadata, + data=entity.model_dump(exclude_none=True), + )) + + return artifact + +def create_artifact_from_data( + data: dict, + name: str | None = None, + description: str | None = None, + media_type: str | None = None, + artifact_id: str | None = None, +) -> Artifact | None: + if data is None: + return None + + return Artifact( + artifact_id=artifact_id if artifact_id else str(uuid4()), # check .NET + name=name, + description=description, + parts=[ + Part( + data=data, + metadata=_get_a2a_metadata(data, media_type or data.__class__.__name__) + ) + ] + ) + +def activity_to_message(context_id: str, task_id: str, activity: Activity, include_entities: bool = True) -> Message: + artifact = create_artifact(activity, include_entities=include_entities) + return Message( + task_id=task_id, + context_id=context_id, + message_id=str(uuid4()), + parts=artifact.parts, + role=RoleTypes.agent, + ) + +def has_message_content(activity: Activity) -> bool: + return bool(activity.text) or bool(activity.attachments) + +def get_task_state(self) -> TaskState: + if self.input_hint == InputHints.expecting_input: + return TaskState.TASK_STATE_INPUT_REQUIRED + return TaskState.TASK_STATE_WORKING + +def get_incoming_message(context: TurnContext) -> Message: + data = context.activity.channel_data + if isinstance(data, Message): + return cast(Message, data) + raise TypeError("Expected channel_data to be of type Message") \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py new file mode 100644 index 000000000..8806cd0a6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py @@ -0,0 +1,149 @@ +import logging +from os import sync +from typing import Awaitable, Callable, cast + +from fastapi import Request, Response + +from a2a.types import ( + SendMessageConfiguration +) +from a2a.server.request_handlers import RequestHandler +from a2a.server.agent_execution import RequestContext +from a2a.server.events import EventQueue + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + CallerIdConstants, + Channels, + ConversationParameters, + ConversationReference, + InvokeResponse, + ResourceResponse, + StreamInfo, +) +from microsoft_agents.hosting.core import ( + AuthenticationConstants, + ChannelAdapter, + ClaimsIdentity, + TurnContext, + ChannelServiceAdapter, +) +from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol + +from ..request_handlers import A2ARequestHandler +from ..activity import utils, A2AActivity +from .agent_request_context import AgentRequestContext + +logger = logging.getLogger(__name__) + +class A2AAdapter(ChannelAdapter, ChannelAdapterProtocol): + + def __init__(self, adapter: ChannelAdapterProtocol): + + self._adapter = adapter + self._a2a_request_handler = A2ARequestHandler( + self, + None, + None, + ) + self._context_map: dict[str, AgentRequestContext] = {} + + @property + def a2a_request_handler(self) -> RequestHandler: + return self._a2a_request_handler + + async def execute_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + + if not context.message: + raise ValueError("Context message is required.") + + self._context_map[context.request_id] = AgentRequestContext( + request_id= + identity= + event_queue=event_queue, + ) + + activity = A2AActivity.from_message( + "", + context.task_id, + context.message, + ) + + await self.process_activity_with_a2a(identity, activity) + + def _create_turn_context( + self, + claims_identity: ClaimsIdentity, + oauth_scope: str | None = None, + activity: Activity | None = None, + ) -> TurnContext: + context = TurnContext(self, activity, claims_identity) + context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] = oauth_scope + context.turn_state[ChannelServiceAdapter.AGENT_IDENTITY_KEY] = claims_identity # for back-compat + return context + + async def process_activity_with_a2a( + self, + claims_identity: ClaimsIdentity, + activity: Activity, + callback: Callable[[TurnContext], Awaitable], + ) -> InvokeResponse | None: + + if activity.channel_id != Channels.a2a: + raise ValueError("Activity channel_id must be 'a2a'") + + outgoing_audience: str | None = None + + if claims_identity.is_agent_claim(): + outgoing_audience = claims_identity.get_token_audience() + activity.caller_id = f"{CallerIdConstants.agent_to_agent_prefix}{claims_identity.get_outgoing_app_id()}" + else: + outgoing_audience = AuthenticationConstants.AGENTS_SDK_SCOPE + + # Create a turn context and run the pipeline. + context = self._create_turn_context( + claims_identity, + outgoing_audience, + activity=activity, + ) + context = self._create_turn_context(claims_identity, Channels.a2a, activity) + + await self.run_pipeline(context, callback) + + async def send_activities( + self, context: TurnContext, activities: list[Activity] + ) -> list[ResourceResponse]: + + await self._a2a_request_handler. + + for activity in activities: + + if activity.channel_id != Channels.a2a: + continue + + entity = activity.get_streaming_entity() + + if entity is not None: + await self._on_streaming_response(context, activity, entity) + elif activity.type == ActivityTypes.message: + await self._on_message_response(context, activity) + elif activity.type == ActivityTypes.end_of_conversation: + await self._on_end_of_conversation_response(context, activity) + else: + logger.debug("A2AAdapter: Unhandled Activity Type: %s", activity.type) + + return await self._adapter.send_activities(context, activities) + + async def _on_streaming_response(self, context: TurnContext, activity: Activity, entity: StreamInfo): + pass + + async def _on_message_response(self, context: TurnContext, activity: Activity): + message = utils.get_incoming_message(context) + state = utils.get_task_state(activity) + response = utils.activity_to_message(message.context_id, message.task_id, activity) + + await event_queue + + async def _on_end_of_conversation_response(self, context: TurnContext, activity: Activity): + pass \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py new file mode 100644 index 000000000..266a780b0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py @@ -0,0 +1,32 @@ +from fastapi import Request, Response + +from microsoft_agents.hosting.fastapi import AgentHttpAdapter + +from .a2a_adapter import A2AAdapter + +from microsoft_agents.hosting.core import ( + Agent, + Connections, + ChannelServiceClientFactoryBase, + OutboundHostValidator, +) +from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol + +from microsoft_agents.hosting.fastapi import CloudAdapter + +class _CloudAdapter(AgentHttpAdapter, ChannelAdapterProtocol): + pass + +class A2ACloudAdapter(A2AAdapter, AgentHttpAdapter): + + def __init__(self): + pass + + async def process(self, request: Request, agent: Agent) -> Response | None: + + adapted_request = FastApiRequestAdapter(request) + + # Process using base implementation + http_response: HttpResponse = await self.process_request(adapted_request, agent) + + return await self.process(request, agent) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py new file mode 100644 index 000000000..e772fd5cd --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from a2a.server.events import EventQueue + +from microsoft_agents.hosting.core import ClaimsIdentity + +@dataclass +class AgentRequestContext: + request_id: str + identity: ClaimsIdentity + event_queue: EventQueue \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/agent_request_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/agent_request_context.py new file mode 100644 index 000000000..2129f3a4c --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/agent_request_context.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import TYPE_CHECKING +from uuid import uuid4 + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events.event_queue_v2 import EventQueue + +from fastapi import Request + + +from microsoft_agents.hosting.core import ( + Agent, + ClaimsIdentity +) + +if TYPE_CHECKING: + from .a2a_adapter import A2AAdapter + +class AgentRequestContext(AgentExecutor): + + def __init__(self, request: Request, adapter: "A2AAdapter", agent: Agent): + + self._adapter = adapter + self._agent = agent + self._identity = ClaimsIdentity() # TODO! + self._event_queue: EventQueue | None = None + self._request_id = request.headers.get("X-Request-ID") or uuid4() # TODO! + + @property + def adapter(self) -> "A2AAdapter": + return self._adapter + + @property + def agent(self) -> Agent: + return self._agent + + @property + def identity(self) -> ClaimsIdentity: + return self._identity + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + self._event_queue = event_queue + # if not context.is_continuation + + # task_updater = context.task_updater + + await self._adapter.execute_agent_turn(self._request_id, self._identity, self._agent, context, event_queue) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + pass \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/http.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/http.py new file mode 100644 index 000000000..cdebdbc1b --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/http.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from a2a.server.request_handlers import RequestHandler + +async def get_task(handler: RequestHandler, \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/json_rpc.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/json_rpc.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py new file mode 100644 index 000000000..e5b8186de --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py @@ -0,0 +1,7 @@ +from .a2a_agent_executor import A2AAgentExecutor +from .a2a_request_handler import A2ARequestHandler + +__all__ = [ + "A2AAgentExecutor", + "A2ARequestHandler" +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py new file mode 100644 index 000000000..f9a5e671c --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py @@ -0,0 +1,56 @@ +import logging + +from a2a.server.agent_execution import ( + AgentExecutor, + RequestContext +) +from a2a.server.events import EventQueue + +from microsoft_agents.activity import ( + Activity, +) +from microsoft_agents.hosting.core import ( + Agent, + ChannelServiceAdapter, + ClaimsIdentity, +) +from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol + + +from ..activity import A2AActivity, utils + +logger = logging.getLogger(__name__) + +class A2AAgentExecutor(AgentExecutor): + + def __init__( + self, + # identity: ClaimsIdentity, + adapter: ChannelAdapterProtocol, + ): + # self._identity = identity + self._adapter = adapter + + async def execute( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + + if not context.message: + logger.warning("No message found in the request context. Dropping request.") + return + + await self._adapter.execute_agent_turn( + context, + event_queue, + ) + + async def cancel( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + pass + + \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py new file mode 100644 index 000000000..3e1f740f0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py @@ -0,0 +1,23 @@ +from a2a.types import AgentCard + +from a2a.server.tasks import TaskStore +from a2a.server.request_handlers import RequestHandler, DefaultRequestHandlerV2 + +from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol + +from .agent_executor import A2AAgentExecutor + +class A2ARequestHandler(DefaultRequestHandlerV2): + + def __init__( + self, + adapter: ChannelAdapterProtocol, + task_store: TaskStore, + agent_card: AgentCard, + ): + self._adapter = adapter + super().__init__( + A2AAgentExecutor(self._adapter), + task_store, + agent_card + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/setup.py b/libraries/microsoft-agents-hosting-a2a/setup.py index c24af4dca..f2166db99 100644 --- a/libraries/microsoft-agents-hosting-a2a/setup.py +++ b/libraries/microsoft-agents-hosting-a2a/setup.py @@ -13,6 +13,7 @@ version=package_version, install_requires=[ f"microsoft-agents-hosting-core=={package_version}", + f"microsoft-agents-hosting-fastapi=={package_version}", "a2a-sdk>=1.0.0", ], ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py index 6499eefbe..77e0a5f33 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py @@ -9,7 +9,6 @@ from collections.abc import Callable from typing import Awaitable from microsoft_agents.hosting.core.authorization import ClaimsIdentity -from microsoft_agents.activity import ChannelAdapterProtocol from microsoft_agents.activity import ( Activity, ChannelId, @@ -21,6 +20,7 @@ from .turn_context import TurnContext from .middleware_set import MiddlewareSet, Middleware +from .channel_adapter_protocol import ChannelAdapterProtocol class ChannelAdapter(ABC, ChannelAdapterProtocol): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter_protocol.py new file mode 100644 index 000000000..8652b2b1a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter_protocol.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import abstractmethod +from typing import Protocol, Callable, Awaitable + +from typing_extensions import Self + +from .turn_context import TurnContext +from microsoft_agents.activity import ( + Activity, + ResourceResponse, + ConversationReference, + ConversationParameters, +) + + +class ChannelAdapterProtocol(Protocol): + on_turn_error: Callable[[TurnContext, Exception], Awaitable] | None + + @abstractmethod + async def send_activities( + self, context: TurnContext, activities: list[Activity] + ) -> list[ResourceResponse]: + pass + + @abstractmethod + async def update_activity( + self, context: TurnContext, activity: Activity + ) -> None: + pass + + @abstractmethod + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ) -> None: + pass + + @abstractmethod + def use(self, middleware: object) -> Self: + pass + + @abstractmethod + async def continue_conversation( + self, + agent_id: str, + reference: ConversationReference, + callback: Callable[[TurnContext], Awaitable], + ) -> None: + pass + + # TODO: potentially move ClaimsIdentity to activity + @abstractmethod + async def continue_conversation_with_claims( + self, + claims_identity: dict, + continuation_activity: Activity, + callback: Callable[[TurnContext], Awaitable], + audience: str | None = None, + ): + pass + + @abstractmethod + async def create_conversation( + self, + agent_app_id: str, + channel_id: str, + service_url: str, + audience: str, + conversation_parameters: ConversationParameters, + callback: Callable[[TurnContext], Awaitable], + ) -> None: + pass + + @abstractmethod + async def run_pipeline( + self, + context: TurnContext, + callback: Callable[[TurnContext], Awaitable], + ) -> None: + pass diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py index c56f913e0..050c6fb9b 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -65,6 +65,85 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: # Convert HttpResponse to FastAPI Response return self._to_fastapi_response(http_response) + async def process_request(self, request: HttpRequestProtocol, agent: Agent) -> HttpResponse: + """Process an incoming HTTP request. + + Args: + request: The HTTP request to process. + agent: The agent to handle the request. + + Returns: + HttpResponse with the result. + + Raises: + TypeError: If request or agent is None. + """ + if not request: + raise TypeError("HttpAdapterBase.process_request: request can't be None") + if not agent: + raise TypeError("HttpAdapterBase.process_request: agent can't be None") + + with spans.AdapterProcess() as span: + + if request.method != "POST": + return HttpResponseFactory.method_not_allowed() + + try: + body = await request.json() + except Exception: + return HttpResponseFactory.bad_request( + "Invalid JSON or unsupported Content-Type" + ) + + activity: Activity = Activity.model_validate(body) + span.share(activity=activity) + + # Get claims identity (default to anonymous if not set by middleware) + claims_identity: ClaimsIdentity = ( + request.get_claims_identity() or ClaimsIdentity() + ) + + # Validate required activity fields + if ( + not activity.type + or not activity.conversation + or not activity.conversation.id + ): + return HttpResponseFactory.bad_request( + "Activity must have type and conversation.id" + ) + + if not self._validate_service_url(claims_identity, activity): + return HttpResponseFactory.unauthorized( + "Service URL is not allowed by the host validator." + ) + + try: + # Process the inbound activity with the agent + invoke_response = await self.process_activity( + claims_identity, activity, agent.on_turn + ) + + # Check if we need to return a synchronous response + if ( + activity.type == "invoke" + or activity.delivery_mode == DeliveryModes.expect_replies + ): + with spans.AdapterWriteResponse(activity): + # Invoke and ExpectReplies cannot be performed async + invoke_response_status = ( + invoke_response.status if invoke_response else None + ) + return HttpResponseFactory.json( + invoke_response.body if invoke_response else None, + invoke_response_status or HTTPStatus.NOT_IMPLEMENTED, + ) + + return HttpResponseFactory.accepted() + + except PermissionError: + return HttpResponseFactory.unauthorized() + @staticmethod def _to_fastapi_response(http_response: HttpResponse) -> Response: """Convert HttpResponse to FastAPI Response.""" From 6c804bf4325b85b2f1abb7853ea1e0fd150325de Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 18 Sep 2026 10:47:23 -0700 Subject: [PATCH 04/39] Context state propagation for ClaimsIdentity --- .../hosting/a2a/a2a_client.py | 32 ++++++++++ .../hosting/a2a/a2a_turn_context.py | 34 +++++++---- .../hosting/a2a/adapter/a2a_adapter.py | 59 +++++++++++++------ .../hosting/a2a/adapter/a2a_cloud_adapter.py | 8 ++- .../microsoft_agents/hosting/a2a/constants.py | 1 + .../request_handlers/a2a_request_handler.py | 3 +- .../microsoft_agents/hosting/a2a/routes.py | 22 +++++++ .../hosting/a2a/server/__init__.py | 8 +++ .../hosting/a2a/server/routes.py | 33 +++++++++++ .../server/sdk_server_call_context_builder.py | 37 ++++++++++++ 10 files changed, 205 insertions(+), 32 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/routes.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py new file mode 100644 index 000000000..7b3ed7d68 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py @@ -0,0 +1,32 @@ +from a2a.server.events import EventQueue +from a2a.server.agent_execution import RequestContext +from a2a.server.tasks import TaskStore + +from microsoft_agents.hosting.core import TurnContext + +class A2AClient: + + def __init__(self, context: TurnContext): + + event_queue = context.services.get(EventQueue) + request_context = context.services.get(RequestContext) + task_store = context.services.get(TaskStore) + + if not event_queue or not request_context or not task_store: + raise ValueError("Missing required services in TurnContext.") + + self._event_queue = event_queue + self._request_context = request_context + self._task_store = task_store + + @property + def event_queue(self) -> EventQueue: + return self._event_queue + + @property + def request_context(self) -> RequestContext: + return self._request_context + + @property + def task_store(self) -> TaskStore: + return self._task_store \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py index 544d62a95..749e12dfc 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py @@ -7,13 +7,17 @@ from typing import cast +from microsoft_agents.activity import Activity from microsoft_agents.hosting.core import ( AgentApplication, TurnContext, + ChannelServiceAdapter, + ClaimsIdentity, ) from .activity import A2AActivity +from .a2a_client import A2AClient class A2ATurnContext(TurnContext): """A context object for handling A2A-specific turn functionality. @@ -22,24 +26,34 @@ class A2ATurnContext(TurnContext): receive a typed context without changing the core routing engine. """ - def __init__(self, context: TurnContext, app: AgentApplication) -> None: - """Initialise the Teams turn context from a plain turn context. + def __init__( + self, + adapter_or_context: ChannelServiceAdapter | TurnContext, + app: AgentApplication, + activity: Activity | None = None, + identity: ClaimsIdentity | None = None, + ) -> None: + + if isinstance(adapter_or_context, TurnContext): + super().__init__(adapter_or_context) + self._original = adapter_or_context + else: + super().__init__(adapter_or_context, activity, identity) + self._original = self - :param context: The base turn context provided by the core runtime. - :param app: The agent application that is handling the turn. - """ - super().__init__(context) self._app = app - self._turn_state = context.turn_state - - self._original = context - + self._turn_state = self.turn_state self._set_a2a_activity() + self._client = A2AClient(self) def _set_a2a_activity(self) -> None: self._activity.__class__ = A2AActivity self._a2a_activity = cast(A2AActivity, self._activity) + @property + def client(self) -> A2AClient: + return self._client + @property def responded(self) -> bool: return self._original.responded diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py index 8806cd0a6..06f865cab 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py @@ -1,6 +1,6 @@ import logging -from os import sync from typing import Awaitable, Callable, cast +from uuid import uuid4 from fastapi import Request, Response @@ -23,6 +23,7 @@ StreamInfo, ) from microsoft_agents.hosting.core import ( + Agent, AuthenticationConstants, ChannelAdapter, ClaimsIdentity, @@ -30,11 +31,14 @@ ChannelServiceAdapter, ) from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol +from microsoft_agents.hosting.core.http._http_request_protocol import HttpRequestProtocol from ..request_handlers import A2ARequestHandler from ..activity import utils, A2AActivity from .agent_request_context import AgentRequestContext +from ..constants import _CLAIMS_IDENTITY_KEY + logger = logging.getLogger(__name__) class A2AAdapter(ChannelAdapter, ChannelAdapterProtocol): @@ -53,24 +57,40 @@ def __init__(self, adapter: ChannelAdapterProtocol): def a2a_request_handler(self) -> RequestHandler: return self._a2a_request_handler - async def execute_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + async def execute_agent_turn( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: if not context.message: raise ValueError("Context message is required.") - - self._context_map[context.request_id] = AgentRequestContext( - request_id= - identity= - event_queue=event_queue, - ) + + identity = context.call_context.state.get(_CLAIMS_IDENTITY_KEY, ClaimsIdentity()) + if not isinstance(identity, ClaimsIdentity): + raise RuntimeError("Invalid identity in context call state.") + + request_id = str(uuid4()) activity = A2AActivity.from_message( - "", + request_id, context.task_id, context.message, ) + activity.request_id = request_id + + self._context_map[request_id] = AgentRequestContext( + request_id=request_id, + identity=identity, + event_queue=event_queue, + ) - await self.process_activity_with_a2a(identity, activity) + await self._process_activity_with_a2a( + identity, + activity, + context, + event_queue, + ) def _create_turn_context( self, @@ -83,11 +103,13 @@ def _create_turn_context( context.turn_state[ChannelServiceAdapter.AGENT_IDENTITY_KEY] = claims_identity # for back-compat return context - async def process_activity_with_a2a( + async def _process_activity_with_a2a( self, - claims_identity: ClaimsIdentity, + identity: ClaimsIdentity, activity: Activity, - callback: Callable[[TurnContext], Awaitable], + request_context: RequestContext, + event_queue: EventQueue, + callback: Callable[[TurnContext], Awaitable] | None = None, ) -> InvokeResponse | None: if activity.channel_id != Channels.a2a: @@ -95,19 +117,20 @@ async def process_activity_with_a2a( outgoing_audience: str | None = None - if claims_identity.is_agent_claim(): - outgoing_audience = claims_identity.get_token_audience() - activity.caller_id = f"{CallerIdConstants.agent_to_agent_prefix}{claims_identity.get_outgoing_app_id()}" + if identity.is_agent_claim(): + outgoing_audience = identity.get_token_audience() + activity.caller_id = f"{CallerIdConstants.agent_to_agent_prefix}{identity.get_outgoing_app_id()}" else: outgoing_audience = AuthenticationConstants.AGENTS_SDK_SCOPE # Create a turn context and run the pipeline. context = self._create_turn_context( - claims_identity, + identity, outgoing_audience, activity=activity, ) - context = self._create_turn_context(claims_identity, Channels.a2a, activity) + context.services.set(RequestContext, request_context) + context.services.set(EventQueue, event_queue) await self.run_pipeline(context, callback) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py index 266a780b0..7aaadd2b0 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py @@ -26,7 +26,9 @@ async def process(self, request: Request, agent: Agent) -> Response | None: adapted_request = FastApiRequestAdapter(request) - # Process using base implementation - http_response: HttpResponse = await self.process_request(adapted_request, agent) + # Process using base implementation + http_response: HttpResponse = await self.process_request(adapted_request, agent) - return await self.process(request, agent) \ No newline at end of file + + + return await self.process_request(request, agent) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py index e69de29bb..08afeb2f0 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py @@ -0,0 +1 @@ +_CLAIMS_IDENTITY_KEY = "__CLAIMS_IDENTITY_KEY" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py index 3e1f740f0..48d3c07ca 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py @@ -20,4 +20,5 @@ def __init__( A2AAgentExecutor(self._adapter), task_store, agent_card - ) \ No newline at end of file + ) + \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/routes.py new file mode 100644 index 000000000..4c81a8277 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/routes.py @@ -0,0 +1,22 @@ +from a2a.server.agent_execution import RequestHandler +from a2a.server.routes import ( + create_jsonrpc_routes as _create_jsonrpc_routes, + create_rest_routes as _create_rest_routes, +) + +from starlette.routing import BaseRoute, Mount, Route + +def create_jsonrpc_routes( + request_handler: RequestHandler, + enable_v0_3_compat: bool = False, + path_prefix: str = "" +) -> list[BaseRoute]: + + + + return _create_jsonrpc_routes( + request_handler, + enable_v0_3_compat=enable_v0_3_compat, + context_builder=context_builder, + path_prefix=path_prefix + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py new file mode 100644 index 000000000..0638d4ae9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py @@ -0,0 +1,8 @@ +from .routes import create_jsonrpc_routes, create_rest_routes +from .sdk_server_call_context_builder import SDKServerCallContextBuilder + +__all__ = [ + "create_jsonrpc_routes", + "create_rest_routes", + "SDKServerCallContextBuilder", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py new file mode 100644 index 000000000..8dd41ce28 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -0,0 +1,33 @@ +from a2a.server.routes import ( + create_jsonrpc_routes as _create_jsonrpc_routes, + create_rest_routes as _create_rest_routes, +) +from a2a.server.request_handlers import RequestHandler + +from starlette.routing import BaseRoute, Route + +from .sdk_server_call_context_builder import SDKServerCallContextBuilder + +def create_jsonrpc_routes( + request_handler: RequestHandler, + rpc_url: str, + enable_v0_3_compat: bool = False, +) -> list[Route]: + return _create_jsonrpc_routes( + request_handler, + rpc_url, + context_builder=SDKServerCallContextBuilder(), + enable_v0_3_compat=enable_v0_3_compat, + ) + +def create_rest_routes( + request_handler: RequestHandler, + path_prefix: str = "", + enable_v0_3_compat: bool = False, +) -> list[BaseRoute]: + return _create_rest_routes( + request_handler, + context_builder=SDKServerCallContextBuilder(), + enable_v0_3_compat=enable_v0_3_compat, + path_prefix=path_prefix, + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py new file mode 100644 index 000000000..ecedf9a91 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py @@ -0,0 +1,37 @@ +from a2a.server.context import ServerCallContext +from a2a.server.routes import DefaultServerCallContextBuilder +from a2a.extensions.common import ( + HTTP_EXTENSION_HEADER, + get_requested_extensions, +) + +from starlette.requests import Request + +from ..constants import _CLAIMS_IDENTITY_KEY + +class SDKServerCallContextBuilder(DefaultServerCallContextBuilder): + """A default implementation of ServerCallContextBuilder.""" + + def build(self, request: Request) -> ServerCallContext: + """Builds a ServerCallContext from a Starlette Request. + + Args: + request: The incoming Starlette Request object. + + Returns: + A ServerCallContext instance populated with user and state + information from the request. + """ + state = {} + if "auth" in request.scope: + state["auth"] = request.auth + state["headers"] = dict(request.headers) + state[_CLAIMS_IDENTITY_KEY] = request.state.claims_identity + + return ServerCallContext( + user=self.build_user(request), + state=state, + requested_extensions=get_requested_extensions( + request.headers.getlist(HTTP_EXTENSION_HEADER) + ), + ) \ No newline at end of file From 602780afb80559065f65b79a68b09a53b957ec40 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 18 Sep 2026 13:03:30 -0700 Subject: [PATCH 05/39] Adding get_streaming_entity method to Activity and related tests --- .../microsoft_agents/activity/activity.py | 22 +++++++++- tests/activity/test_activity.py | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 54b49fab0..fe77e7a63 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -38,6 +38,7 @@ ClientCitation, ProductInfo, SensitivityUsageInfo, + StreamInfo, ) from .entity._validate_known_entities import _validate_known_entities from .conversation_reference import ConversationReference @@ -1076,7 +1077,11 @@ def _convert_entity_list( entities.append(Activity._convert_entity(e, entity_cls)) return entities - def get_product_info_entity(self) -> Optional[ProductInfo]: + def get_product_info_entity(self) -> ProductInfo | None: + """Gets the product info entity from the activity's entities. + + :return: The product info entity if found, otherwise None. + """ if not self.entities: return None target = EntityTypes.PRODUCT_INFO.lower() @@ -1089,6 +1094,21 @@ def get_product_info_entity(self) -> Optional[ProductInfo]: return None return Activity._convert_entity(raw_product_info, ProductInfo) + def get_streaming_entity(self) -> StreamInfo | None: + """Gets the streaming entity from the activity's entities. + + :return: The streaming entity if found, otherwise None. + """ + if not self.entities: + return None + target = EntityTypes.STREAM_INFO.lower() + raw_stream_info = next( + filter(lambda e: e.type.lower() == target, self.entities), None + ) + if raw_stream_info is None: + return None + return Activity._convert_entity(raw_stream_info, StreamInfo) + def get_mentions(self) -> list[Mention]: """ Resolves the mentions from the entities of this activity. diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index f073f921c..55ac0a3aa 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -19,6 +19,7 @@ Thing, ProductInfo, RoleTypes, + StreamInfo, ) from tests.activity._common.my_channel_data import MyChannelData @@ -470,6 +471,46 @@ def test_get_product_info_entity_single(self, entities, expected): retrieved_product_info = activity.get_product_info_entity() assert retrieved_product_info == expected + @pytest.mark.parametrize( + "entities, expected", + [ + [ + [ + { + "type": "streaminfo", + "streamId": "stream_123", + "streamSequence": 1, + }, + {"type": "other"}, + ], + StreamInfo(stream_id="stream_123", stream_sequence=1), + ], + [ + [ + {"type": "other"}, + {"type": "mention", "text": "Another mention"}, + ], + None, + ], + [ + [ + {"type": "StreamInfo", "streamId": "stream_123"}, + {"type": "StreamInfo", "streamId": "stream_456"}, + ], + StreamInfo(stream_id="stream_123"), + ], + [[], None], + [None, None], + ], + ) + def test_get_streaming_entity(self, entities, expected): + activity = Activity(type="message", entities=entities) + for entity in activity.entities or []: + if entity.type.casefold() == EntityTypes.STREAM_INFO.value.casefold(): + assert isinstance(entity, StreamInfo) + + assert activity.get_streaming_entity() == expected + class TestActivityAsTypeHelpers: @pytest.mark.parametrize( From 821ff0e31b75fba657fc54c99f9ba3d3ac229911 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 18 Sep 2026 14:29:06 -0700 Subject: [PATCH 06/39] SendActivities handling --- .../activity/end_of_conversation_codes.py | 1 + .../hosting/a2a/activity/utils.py | 16 +++- .../hosting/a2a/adapter/a2a_adapter.py | 95 ++++++++++++++++++- .../hosting/core/_utils/_service_set.py | 18 +++- 4 files changed, 117 insertions(+), 13 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/end_of_conversation_codes.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/end_of_conversation_codes.py index 425cc603f..7d9294c69 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/end_of_conversation_codes.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/end_of_conversation_codes.py @@ -11,3 +11,4 @@ class EndOfConversationCodes(str, Enum): timed_out = "botTimedOut" issued_invalid_message = "botIssuedInvalidMessage" channel_failed = "channelFailed" + error = "error" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 2e62afd56..4556a99c3 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -1,4 +1,4 @@ -from typing import Mapping, cast +from typing import Mapping, cast, Sequence from uuid import uuid4 from a2a.types import ( @@ -90,13 +90,21 @@ def create_artifact_from_data( ] ) -def activity_to_message(context_id: str, task_id: str, activity: Activity, include_entities: bool = True) -> Message: - artifact = create_artifact(activity, include_entities=include_entities) +def create_message(context_id: str, task_id: str, activity: Activity | None, include_entities: bool = True) -> Message: + + parts: Sequence[Part] | None + + if not activity: + parts = None + else: + artifact = activity_to_artifact(activity, include_entities=include_entities) + parts = artifact.parts + return Message( task_id=task_id, context_id=context_id, message_id=str(uuid4()), - parts=artifact.parts, + parts=parts, role=RoleTypes.agent, ) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py index 06f865cab..5508f8ef6 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py @@ -1,11 +1,17 @@ import logging + +from datetime import datetime, timezone from typing import Awaitable, Callable, cast from uuid import uuid4 from fastapi import Request, Response from a2a.types import ( - SendMessageConfiguration + SendMessageConfiguration, + TaskStatusUpdateEvent, + TaskArtifactUpdateEvent, + TaskStatus, + TaskState, ) from a2a.server.request_handlers import RequestHandler from a2a.server.agent_execution import RequestContext @@ -18,6 +24,7 @@ Channels, ConversationParameters, ConversationReference, + EndOfConversationCodes, InvokeResponse, ResourceResponse, StreamInfo, @@ -159,14 +166,92 @@ async def send_activities( return await self._adapter.send_activities(context, activities) async def _on_streaming_response(self, context: TurnContext, activity: Activity, entity: StreamInfo): - pass + message = utils.get_incoming_message(context) + is_informative = entity.stream_type == "informative" + + event_queue = context.services.get(EventQueue, raise_if_missing=True) + + if is_informative: + await event_queue.enqueue_event(TaskStatusUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + status=TaskStatus( + state=TaskState.TASK_STATE_WORKING, + timestamp=datetime.now(timezone.utc), + message=utils.create_message(message.context_id, message.task_id, activity) + ) + )) + else: + artifact = utils.activity_to_artifact(activity, entity.stream_id) + await event_queue.enqueue_event(TaskArtifactUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + artifact=artifact, + append=False, + last_chunk=True, + )) async def _on_message_response(self, context: TurnContext, activity: Activity): message = utils.get_incoming_message(context) state = utils.get_task_state(activity) - response = utils.activity_to_message(message.context_id, message.task_id, activity) + response = utils.create_message(message.context_id, message.task_id, activity) - await event_queue + event_queue = context.services.get(EventQueue, raise_if_missing=True) + + await event_queue.enqueue_event(TaskStatusUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + status=TaskStatus( + state=state, + timestamp=datetime.now(timezone.utc), + message=response + ) + )) async def _on_end_of_conversation_response(self, context: TurnContext, activity: Activity): - pass \ No newline at end of file + message = utils.get_incoming_message(context) + event_queue = context.services.get(EventQueue, raise_if_missing=True) + + if isinstance(activity.value, dict): + artifact = utils.create_artifact_from_data( + activity.value, + name="Result", + description="Task completion result", + media_type="application/json" + ) + await event_queue.enqueue_event(TaskArtifactUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + artifact=artifact, + append=False, + last_chunk=True, + )) + + task_state: TaskState + if activity.code == EndOfConversationCodes.error: + task_state = TaskState.TASK_STATE_FAILED + elif activity.code == EndOfConversationCodes.user_cancelled: + task_state = TaskState.TASK_STATE_CANCELED + else: + task_state = TaskState.TASK_STATE_COMPLETED + + status_message: Activity + if utils.has_message_content(activity): + status_message = activity.model_copy() + status_message.value = None + + response = utils.create_message( + message.context_id, + message.task_id, + status_message, + ) + + await event_queue.enqueue_event(TaskStatusUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + status=TaskStatus( + state=task_state, + timestamp=datetime.now(timezone.utc), + message=response + ) + )) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py index 283231dfa..5039a8a16 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import TypeVar, cast, Any +from typing import TypeVar, cast, Any, overload, Literal T = TypeVar("T") @@ -23,11 +23,17 @@ def __init__(self, service_set: _ServiceSet | None = None) -> None: if service_set is not None: self._state.update(service_set._state) - def get(self, key: type[T]) -> T | None: + @overload + def get(self, key: type[T], raise_if_missing: Literal[True] = True) -> T: ... + + @overload + def get(self, key: type[T], raise_if_missing: Literal[False] = False) -> T | None: ... + def get(self, key: type[T], raise_if_missing: bool = False) -> T | None: """ Gets a value from the state collection. - :param key: - :return: + :param key: The type of the value to retrieve. + :param raise_if_missing: Whether to raise an exception if the value is missing. + :return: The value associated with the specified type, or None if not found and raise_if_missing is False. """ val = self._state.get(key) if val is not None: @@ -39,6 +45,10 @@ def get(self, key: type[T]) -> T | None: f"Value for key '{key.__name__}' is not of type {key.__name__} (got {type(val).__name__})" ) return cast(T, val) + + if raise_if_missing: + raise KeyError(f"Value for key '{key.__name__}' is missing") + return None def has(self, key: type) -> bool: From 5f5ff651333fbe3edc474ee4f339e5020cb268de Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 18 Sep 2026 14:41:35 -0700 Subject: [PATCH 07/39] Removing files --- .../hosting/a2a/a2a_adapter.py | 129 ------------------ .../hosting/a2a/adapter/a2a_adapter.py | 80 +++++++++-- .../hosting/a2a/agent_request_context.py | 52 ------- .../request_handlers/a2a_request_handler.py | 9 +- 4 files changed, 79 insertions(+), 191 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/agent_request_context.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py deleted file mode 100644 index c63cb04c3..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import logging - -from fastapi import Request, Response - -from a2a.server.agent_execution import RequestContext, SimpleRequestContextBuilder -from a2a.server.events.event_queue_v2 import EventQueue - - -from a2a.types import ( - AgentCapabilities, - AgentCard, - AgentInterface, - AgentSkill, - HTTPAuthSecurityScheme, - SecurityScheme, -) -from a2a.utils.constants import TransportProtocol - -from google.protobuf import json_format - -from microsoft_agents.hosting.core import ( - Agent, - ChannelServiceAdapter, - ClaimsIdentity, -) - -from microsoft_agents.hosting.fastapi import CloudAdapter - -from .activity import A2AActivity - -logger = logging.getLogger(__file__) - -class A2AAdapter(): - - def __init__( - self, - adapter: CloudAdapter, - *, - agent_card_name: str | None = None, - agent_card_description: str | None = None, - agent_card_version: str | None = None, - ): - self._inner = adapter - - self._agent_card_name = agent_card_name or self.__class__.__name__ - self._agent_card_description = agent_card_description or "Agents SDK A2A" - self._agent_card_version = agent_card_version or "0.0.0" - - async def process(self, request: Request, agent: Agent) -> Response | None: - return await self.process_json_rpc(request, agent) - - # async def process_json_rpc(self, request: Request, agent: Agent) -> Response | None: - # return await A2AJsonRpcProcessor.process_request( - - # ) - - async def process_agent_card(self, request: Request, agent: Agent, path_prefix: str) -> Response: - agent_card = AgentCard( - name=self._agent_card_name, - description=self._agent_card_description, - version=self._agent_card_version, - security_schemes={ - "jwt": SecurityScheme(http_auth_security_scheme=HTTPAuthSecurityScheme(scheme="bearer")) - }, - default_input_modes=["application/json"], - default_output_modes=["application/json"], - skills=[], - capabilities=AgentCapabilities( - extended_agent_card=True, - streaming=True, - ), - supported_interfaces=[], - ) - - agent_interfaces = [] - if not agent_interfaces: - agent_card.supported_interfaces.append( - AgentInterface( - protocol_binding=TransportProtocol.JSONRPC, - url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", - protocol_version="1.0", - ) - ) - else: - for agent_interface in agent_interfaces: - if agent_interface.protocol in (TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON): - agent_card.supported_interfaces.append( - protocol_binding=agent_interface.protocol, - url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", - protocol_version="1.0", - ) - else: - logger.info("Unsupported protocol: %s", agent_interface.protocol) - - skills = [] - if skills: - for skill_info in skills: - agent_card.skills.append(AgentSkill( - id=skill_info.id, - name=skill_info.name, - description=skill_info.description, - tags=skill_info.tags, - examples=skill_info.examples, - input_modes=skill_info.input_modes, - output_modes=skill_info.output_modes, - )) - - # agent card handler scenario here from .NET... - - logger.debug("Agent card: %s", agent_card) - - return Response( - content=json_format.MessageToJson(agent_card), - media_type="application/json", - ) - - def _create_agent_request_context(self, request: Request, agent: Agent, cache: bool = True) -> RequestContext: - ... - - async def execute_agent_turn(self, request_id: str, identity: ClaimsIdentity, agent: Agent, context: RequestContext, event_queue: EventQueue) -> None: - - if not context.message: - raise ValueError("Context must have a message.") - - activity = A2AActivity.activity_from_message(request_id, context.task_id, context.message) - \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py index 5508f8ef6..28a71626f 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py @@ -7,11 +7,18 @@ from fastapi import Request, Response from a2a.types import ( + AgentCard, SendMessageConfiguration, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TaskStatus, TaskState, + AgentCapabilities, + AgentCard, + AgentInterface, + AgentSkill, + HTTPAuthSecurityScheme, + SecurityScheme, ) from a2a.server.request_handlers import RequestHandler from a2a.server.agent_execution import RequestContext @@ -50,9 +57,12 @@ class A2AAdapter(ChannelAdapter, ChannelAdapterProtocol): - def __init__(self, adapter: ChannelAdapterProtocol): + def __init__( + self, + agent: Agent, + ): - self._adapter = adapter + self._agent = Agent self._a2a_request_handler = A2ARequestHandler( self, None, @@ -116,7 +126,6 @@ async def _process_activity_with_a2a( activity: Activity, request_context: RequestContext, event_queue: EventQueue, - callback: Callable[[TurnContext], Awaitable] | None = None, ) -> InvokeResponse | None: if activity.channel_id != Channels.a2a: @@ -139,14 +148,12 @@ async def _process_activity_with_a2a( context.services.set(RequestContext, request_context) context.services.set(EventQueue, event_queue) - await self.run_pipeline(context, callback) + await self.run_pipeline(context, self._agent.on_turn) async def send_activities( self, context: TurnContext, activities: list[Activity] ) -> list[ResourceResponse]: - await self._a2a_request_handler. - for activity in activities: if activity.channel_id != Channels.a2a: @@ -162,9 +169,9 @@ async def send_activities( await self._on_end_of_conversation_response(context, activity) else: logger.debug("A2AAdapter: Unhandled Activity Type: %s", activity.type) - - return await self._adapter.send_activities(context, activities) + return [] + async def _on_streaming_response(self, context: TurnContext, activity: Activity, entity: StreamInfo): message = utils.get_incoming_message(context) is_informative = entity.stream_type == "informative" @@ -254,4 +261,59 @@ async def _on_end_of_conversation_response(self, context: TurnContext, activity: timestamp=datetime.now(timezone.utc), message=response ) - )) \ No newline at end of file + )) + + def _update_agent_card(self) -> None: + + agent_card = AgentCard( + name=self._agent_card_name, + description=self._agent_card_description, + version=self._agent_card_version, + security_schemes={ + "jwt": SecurityScheme(http_auth_security_scheme=HTTPAuthSecurityScheme(scheme="bearer")) + }, + default_input_modes=["application/json"], + default_output_modes=["application/json"], + skills=[], + capabilities=AgentCapabilities( + extended_agent_card=True, + streaming=True, + ), + supported_interfaces=[], + ) + + agent_interfaces = [] + if not agent_interfaces: + agent_card.supported_interfaces.append( + AgentInterface( + protocol_binding=TransportProtocol.JSONRPC, + url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", + protocol_version="1.0", + ) + ) + else: + for agent_interface in agent_interfaces: + if agent_interface.protocol in (TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON): + agent_card.supported_interfaces.append( + protocol_binding=agent_interface.protocol, + url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", + protocol_version="1.0", + ) + else: + logger.info("Unsupported protocol: %s", agent_interface.protocol) + + skills = [] + if skills: + for skill_info in skills: + agent_card.skills.append(AgentSkill( + id=skill_info.id, + name=skill_info.name, + description=skill_info.description, + tags=skill_info.tags, + examples=skill_info.examples, + input_modes=skill_info.input_modes, + output_modes=skill_info.output_modes, + )) + + self._a2a_request_handler.update_agent_card(agent_card) + diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/agent_request_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/agent_request_context.py deleted file mode 100644 index 2129f3a4c..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/agent_request_context.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import TYPE_CHECKING -from uuid import uuid4 - -from a2a.server.agent_execution import AgentExecutor, RequestContext -from a2a.server.events.event_queue_v2 import EventQueue - -from fastapi import Request - - -from microsoft_agents.hosting.core import ( - Agent, - ClaimsIdentity -) - -if TYPE_CHECKING: - from .a2a_adapter import A2AAdapter - -class AgentRequestContext(AgentExecutor): - - def __init__(self, request: Request, adapter: "A2AAdapter", agent: Agent): - - self._adapter = adapter - self._agent = agent - self._identity = ClaimsIdentity() # TODO! - self._event_queue: EventQueue | None = None - self._request_id = request.headers.get("X-Request-ID") or uuid4() # TODO! - - @property - def adapter(self) -> "A2AAdapter": - return self._adapter - - @property - def agent(self) -> Agent: - return self._agent - - @property - def identity(self) -> ClaimsIdentity: - return self._identity - - async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: - self._event_queue = event_queue - # if not context.is_continuation - - # task_updater = context.task_updater - - await self._adapter.execute_agent_turn(self._request_id, self._identity, self._agent, context, event_queue) - - async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: - pass \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py index 48d3c07ca..c30a35e44 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py @@ -21,4 +21,11 @@ def __init__( task_store, agent_card ) - \ No newline at end of file + + + def update_agent_card(self, agent_card: AgentCard) -> None: + """Update the agent card with the provided AgentCard instance. + + :param agent_card: The AgentCard instance to update the handler with. + """ + self._agent_card = agent_card \ No newline at end of file From cede7debc4b260520758daa68aff9996086f93ba Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 18 Sep 2026 14:43:58 -0700 Subject: [PATCH 08/39] Removing more files --- .../hosting/a2a/route_handlers.py | 36 +------------------ .../microsoft_agents/hosting/a2a/routes.py | 22 ------------ 2 files changed, 1 insertion(+), 57 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/routes.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py index b7625ca1e..52a0c8a47 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py @@ -48,38 +48,4 @@ async def __func(context: TurnContext, state: _StateContra) -> None: a2a_context = A2ATurnContext(context, app) await handler(a2a_context, state) - return __func - - -class TeamsHandoffHandler(Protocol[_StateContra]): - """Protocol for a Teams handoff handler that receives handoff continuation data.""" - - def __call__( - self, context: TeamsTurnContext, state: _StateContra, handoff_data: str, / - ) -> Awaitable[None]: - """Handle a handoff activity with Teams context. - - :param context: Teams-aware turn context. - :param state: The current turn state. - :param handoff_data: Opaque continuation data from the handoff initiator. - """ - ... - - -def wrap_a2a_handler( - handler: TeamsHandoffHandler[_StateContra], app: AgentApplication -) -> HandoffHandler[_StateContra]: - """Adapt a :class:`TeamsHandoffHandler` into a plain :class:`HandoffHandler`. - - :param handler: The Teams-specific handoff handler to wrap. - :param app: The agent application handling the turn. - :return: A :class:`HandoffHandler` that upgrades the context before delegating. - """ - - async def __func( - context: TurnContext, state: _StateContra, handoff_data: str - ) -> None: - teams_context = TeamsTurnContext(context, app) - await handler(teams_context, state, handoff_data) - - return __func + return __func \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/routes.py deleted file mode 100644 index 4c81a8277..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/routes.py +++ /dev/null @@ -1,22 +0,0 @@ -from a2a.server.agent_execution import RequestHandler -from a2a.server.routes import ( - create_jsonrpc_routes as _create_jsonrpc_routes, - create_rest_routes as _create_rest_routes, -) - -from starlette.routing import BaseRoute, Mount, Route - -def create_jsonrpc_routes( - request_handler: RequestHandler, - enable_v0_3_compat: bool = False, - path_prefix: str = "" -) -> list[BaseRoute]: - - - - return _create_jsonrpc_routes( - request_handler, - enable_v0_3_compat=enable_v0_3_compat, - context_builder=context_builder, - path_prefix=path_prefix - ) \ No newline at end of file From 6f7581f96091d3091856e4528e20f786b461a36f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 18 Sep 2026 14:53:10 -0700 Subject: [PATCH 09/39] Adding default TaskStore creation for A2AAdapter --- .../hosting/a2a/adapter/__init__.py | 9 ++++++++ .../hosting/a2a/adapter/a2a_adapter.py | 21 ++++++++++++------- .../request_handlers/a2a_agent_executor.py | 10 +++------ .../request_handlers/a2a_request_handler.py | 8 +++---- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py index e69de29bb..5f7f6d906 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py @@ -0,0 +1,9 @@ +from .a2a_adapter import A2AAdapter +from .a2a_cloud_adapter import A2ACloudAdapter +from .agent_request_context import AgentRequestContext + +__all__ = [ + "A2AAdapter", + "A2ACloudAdapter", + "AgentRequestContext" +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py index 28a71626f..8977caa8b 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py @@ -23,6 +23,7 @@ from a2a.server.request_handlers import RequestHandler from a2a.server.agent_execution import RequestContext from a2a.server.events import EventQueue +from a2a.server.tasks import TaskStore, InMemoryTaskStore from microsoft_agents.activity import ( Activity, @@ -57,16 +58,18 @@ class A2AAdapter(ChannelAdapter, ChannelAdapterProtocol): - def __init__( - self, - agent: Agent, - ): + def __init__(self, agent: Agent, task_store: TaskStore | None = None): + """Initializes the A2AAdapter with the given agent and optional task store. + + :param agent: The agent instance to be used by the adapter. + :param task_store: Optional task store for managing tasks. If not provided, an in-memory task store will be used. + """ - self._agent = Agent + self._agent = agent self._a2a_request_handler = A2ARequestHandler( self, - None, - None, + task_store or InMemoryTaskStore(), + agent_card = self._get_agent_card(), ) self._context_map: dict[str, AgentRequestContext] = {} @@ -263,7 +266,7 @@ async def _on_end_of_conversation_response(self, context: TurnContext, activity: ) )) - def _update_agent_card(self) -> None: + def _get_agent_card(self) -> AgentCard: agent_card = AgentCard( name=self._agent_card_name, @@ -315,5 +318,7 @@ def _update_agent_card(self) -> None: output_modes=skill_info.output_modes, )) + def _update_agent_card(self) -> None: + agent_card = self._get_agent_card() self._a2a_request_handler.update_agent_card(agent_card) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py index f9a5e671c..4743a2db5 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py @@ -14,10 +14,8 @@ ChannelServiceAdapter, ClaimsIdentity, ) -from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol - - from ..activity import A2AActivity, utils +from ..adapter import A2AAdapter logger = logging.getLogger(__name__) @@ -25,10 +23,8 @@ class A2AAgentExecutor(AgentExecutor): def __init__( self, - # identity: ClaimsIdentity, - adapter: ChannelAdapterProtocol, + adapter: A2AAdapter, ): - # self._identity = identity self._adapter = adapter async def execute( @@ -40,7 +36,7 @@ async def execute( if not context.message: logger.warning("No message found in the request context. Dropping request.") return - + await self._adapter.execute_agent_turn( context, event_queue, diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py index c30a35e44..4ec2dcd0c 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py @@ -1,17 +1,17 @@ from a2a.types import AgentCard from a2a.server.tasks import TaskStore -from a2a.server.request_handlers import RequestHandler, DefaultRequestHandlerV2 +from a2a.server.request_handlers import DefaultRequestHandlerV2 -from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol +from ..adapter import A2AAdapter -from .agent_executor import A2AAgentExecutor +from .a2a_agent_executor import A2AAgentExecutor class A2ARequestHandler(DefaultRequestHandlerV2): def __init__( self, - adapter: ChannelAdapterProtocol, + adapter: A2AAdapter, task_store: TaskStore, agent_card: AgentCard, ): From 286f771bb144049a7ae405e9664b6a79d166f430 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 18 Sep 2026 15:02:06 -0700 Subject: [PATCH 10/39] Initial implementation of A2AAgentExtension --- .../activity/end_of_conversation_codes.py | 2 +- .../microsoft_agents/activity/input_hints.py | 1 - .../microsoft_agents/hosting/a2a/__init__.py | 2 +- .../hosting/a2a/a2a_agent_extension.py | 65 ++++++++ .../hosting/a2a/a2a_client.py | 3 +- .../hosting/a2a/a2a_turn_context.py | 3 +- .../hosting/a2a/activity/__init__.py | 4 +- .../hosting/a2a/activity/utils.py | 43 +++-- .../hosting/a2a/adapter/__init__.py | 6 +- .../hosting/a2a/adapter/a2a_adapter.py | 151 +++++++++++------- .../hosting/a2a/adapter/a2a_cloud_adapter.py | 12 +- .../a2a/adapter/agent_request_context.py | 3 +- .../microsoft_agents/hosting/a2a/constants.py | 2 +- .../hosting/a2a/request_handlers/__init__.py | 5 +- .../request_handlers/a2a_agent_executor.py | 8 +- .../request_handlers/a2a_request_handler.py | 12 +- .../hosting/a2a/route_handlers.py | 16 +- .../hosting/a2a/server/__init__.py | 2 +- .../hosting/a2a/server/routes.py | 4 +- .../server/sdk_server_call_context_builder.py | 3 +- .../hosting/core/_utils/_service_set.py | 6 +- .../hosting/core/channel_adapter_protocol.py | 4 +- .../hosting/fastapi/cloud_adapter.py | 4 +- 23 files changed, 234 insertions(+), 127 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/end_of_conversation_codes.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/end_of_conversation_codes.py index 7d9294c69..1dcebf856 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/end_of_conversation_codes.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/end_of_conversation_codes.py @@ -11,4 +11,4 @@ class EndOfConversationCodes(str, Enum): timed_out = "botTimedOut" issued_invalid_message = "botIssuedInvalidMessage" channel_failed = "channelFailed" - error = "error" \ No newline at end of file + error = "error" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/input_hints.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/input_hints.py index 030f8f407..ce004f3e3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/input_hints.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/input_hints.py @@ -8,4 +8,3 @@ class InputHints(str, Enum): accepting_input = "acceptingInput" ignoring_input = "ignoringInput" expecting_input = "expectingInput" - \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py index cd66ce5e9..7e122b812 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py @@ -7,4 +7,4 @@ __all__ = [ "A2AActivity", "A2ATurnContext", -] \ No newline at end of file +] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py new file mode 100644 index 000000000..8972a9979 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py @@ -0,0 +1,65 @@ +from typing import Callable, Generic, Protocol +from re import Pattern + +from microsoft_agents.hosting.core.app import ( + AgentApplication, + RouteHandler, +) +from microsoft_agents.hosting.core.app._type_defs import StateT + +from .route_handlers import A2ARouteHandler, wrap_a2a_route_handler + + +class _AppRouteDecorator(Protocol[StateT]): + """Protocol for a decorator returned by :class:`TeamsAgentExtension` route methods.""" + + def __call__(self, func: A2ARouteHandler[StateT], /) -> RouteHandler[StateT]: + """Register *func* as a Teams route handler. + + :param func: Teams-aware handler to register. + :return: The wrapped core route handler. + """ + ... + + +class A2AAgentExtension(Generic[StateT]): + + def __init__(self, app: AgentApplication[StateT]) -> None: + + self._app = app + + def _wrap_decorator( + self, decorator: Callable[[RouteHandler[StateT]], RouteHandler[StateT]] + ) -> Callable[[A2ARouteHandler[StateT]], RouteHandler[StateT]]: + """Wrap a core route decorator so it accepts a :class:`TeamsRouteHandler`. + + The returned decorator converts the Teams handler via + :func:`wrap_teams_route_handler` before passing it to *decorator*, keeping the + Teams context upgrade transparent to callers. + + :param decorator: A core route decorator from :class:`AgentApplication`. + :return: A decorator that accepts and registers a :class:`TeamsRouteHandler`. + """ + + def __call(func: A2ARouteHandler[StateT]) -> RouteHandler[StateT]: + return decorator(wrap_a2a_route_handler(func, self._app)) + + return __call + + def message( + self, + select: str | Pattern[str] | list[str | Pattern[str]], + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for message activities matching *select*. + + :param select: A literal string, regex pattern, or list of either to match against + the message text. + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.message(select, auth_handlers=auth_handlers, **kwargs) + ) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py index 7b3ed7d68..c4b8225b9 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py @@ -4,6 +4,7 @@ from microsoft_agents.hosting.core import TurnContext + class A2AClient: def __init__(self, context: TurnContext): @@ -29,4 +30,4 @@ def request_context(self) -> RequestContext: @property def task_store(self) -> TaskStore: - return self._task_store \ No newline at end of file + return self._task_store diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py index 749e12dfc..2c29aee1c 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py @@ -19,6 +19,7 @@ from .a2a_client import A2AClient + class A2ATurnContext(TurnContext): """A context object for handling A2A-specific turn functionality. @@ -68,4 +69,4 @@ def streaming_response(self): @property def activity(self) -> A2AActivity: - return self._a2a_activity \ No newline at end of file + return self._a2a_activity diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py index 4e4b617a8..c31e17a49 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py @@ -1,5 +1,3 @@ from .a2a_activity import A2AActivity -__all__ = [ - "A2AActivity" -] \ No newline at end of file +__all__ = ["A2AActivity"] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 4556a99c3..79b99a6fa 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -19,7 +19,10 @@ _ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" _SCHEMAS: dict[str, Mapping] = {} -def activity_to_artifact(activity: Activity, artifact_id: str | None = None, include_entities: bool = True) -> Artifact: + +def activity_to_artifact( + activity: Activity, artifact_id: str | None = None, include_entities: bool = True +) -> Artifact: artifact = Artifact( artifact_id=artifact_id if artifact_id else str(uuid4()), @@ -57,17 +60,22 @@ def activity_to_artifact(activity: Activity, artifact_id: str | None = None, inc if not isinstance(entity, StreamInfo): if entity.type not in _SCHEMAS: - _SCHEMAS[entity.type] = _to_a2a_metadata(entity, _ENTITY_TYPE_TEMPLATE.format(entity.type)) + _SCHEMAS[entity.type] = _to_a2a_metadata( + entity, _ENTITY_TYPE_TEMPLATE.format(entity.type) + ) cached_metadata = _SCHEMAS[entity.type] - artifact.parts.append(Part( - metadata=cached_metadata, - data=entity.model_dump(exclude_none=True), - )) - + artifact.parts.append( + Part( + metadata=cached_metadata, + data=entity.model_dump(exclude_none=True), + ) + ) + return artifact + def create_artifact_from_data( data: dict, name: str | None = None, @@ -79,21 +87,27 @@ def create_artifact_from_data( return None return Artifact( - artifact_id=artifact_id if artifact_id else str(uuid4()), # check .NET + artifact_id=artifact_id if artifact_id else str(uuid4()), # check .NET name=name, description=description, parts=[ Part( data=data, - metadata=_get_a2a_metadata(data, media_type or data.__class__.__name__) + metadata=_get_a2a_metadata(data, media_type or data.__class__.__name__), ) - ] + ], ) -def create_message(context_id: str, task_id: str, activity: Activity | None, include_entities: bool = True) -> Message: + +def create_message( + context_id: str, + task_id: str, + activity: Activity | None, + include_entities: bool = True, +) -> Message: parts: Sequence[Part] | None - + if not activity: parts = None else: @@ -108,16 +122,19 @@ def create_message(context_id: str, task_id: str, activity: Activity | None, inc role=RoleTypes.agent, ) + def has_message_content(activity: Activity) -> bool: return bool(activity.text) or bool(activity.attachments) + def get_task_state(self) -> TaskState: if self.input_hint == InputHints.expecting_input: return TaskState.TASK_STATE_INPUT_REQUIRED return TaskState.TASK_STATE_WORKING + def get_incoming_message(context: TurnContext) -> Message: data = context.activity.channel_data if isinstance(data, Message): return cast(Message, data) - raise TypeError("Expected channel_data to be of type Message") \ No newline at end of file + raise TypeError("Expected channel_data to be of type Message") diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py index 5f7f6d906..68e37ae85 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py @@ -2,8 +2,4 @@ from .a2a_cloud_adapter import A2ACloudAdapter from .agent_request_context import AgentRequestContext -__all__ = [ - "A2AAdapter", - "A2ACloudAdapter", - "AgentRequestContext" -] \ No newline at end of file +__all__ = ["A2AAdapter", "A2ACloudAdapter", "AgentRequestContext"] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py index 8977caa8b..69ef27c2a 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py @@ -45,8 +45,12 @@ TurnContext, ChannelServiceAdapter, ) -from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol -from microsoft_agents.hosting.core.http._http_request_protocol import HttpRequestProtocol +from microsoft_agents.hosting.core.channel_adapter_protocol import ( + ChannelAdapterProtocol, +) +from microsoft_agents.hosting.core.http._http_request_protocol import ( + HttpRequestProtocol, +) from ..request_handlers import A2ARequestHandler from ..activity import utils, A2AActivity @@ -56,11 +60,12 @@ logger = logging.getLogger(__name__) + class A2AAdapter(ChannelAdapter, ChannelAdapterProtocol): def __init__(self, agent: Agent, task_store: TaskStore | None = None): """Initializes the A2AAdapter with the given agent and optional task store. - + :param agent: The agent instance to be used by the adapter. :param task_store: Optional task store for managing tasks. If not provided, an in-memory task store will be used. """ @@ -69,7 +74,7 @@ def __init__(self, agent: Agent, task_store: TaskStore | None = None): self._a2a_request_handler = A2ARequestHandler( self, task_store or InMemoryTaskStore(), - agent_card = self._get_agent_card(), + agent_card=self._get_agent_card(), ) self._context_map: dict[str, AgentRequestContext] = {} @@ -86,7 +91,9 @@ async def execute_agent_turn( if not context.message: raise ValueError("Context message is required.") - identity = context.call_context.state.get(_CLAIMS_IDENTITY_KEY, ClaimsIdentity()) + identity = context.call_context.state.get( + _CLAIMS_IDENTITY_KEY, ClaimsIdentity() + ) if not isinstance(identity, ClaimsIdentity): raise RuntimeError("Invalid identity in context call state.") @@ -104,7 +111,7 @@ async def execute_agent_turn( identity=identity, event_queue=event_queue, ) - + await self._process_activity_with_a2a( identity, activity, @@ -120,7 +127,9 @@ def _create_turn_context( ) -> TurnContext: context = TurnContext(self, activity, claims_identity) context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] = oauth_scope - context.turn_state[ChannelServiceAdapter.AGENT_IDENTITY_KEY] = claims_identity # for back-compat + context.turn_state[ChannelServiceAdapter.AGENT_IDENTITY_KEY] = ( + claims_identity # for back-compat + ) return context async def _process_activity_with_a2a( @@ -174,32 +183,40 @@ async def send_activities( logger.debug("A2AAdapter: Unhandled Activity Type: %s", activity.type) return [] - - async def _on_streaming_response(self, context: TurnContext, activity: Activity, entity: StreamInfo): + + async def _on_streaming_response( + self, context: TurnContext, activity: Activity, entity: StreamInfo + ): message = utils.get_incoming_message(context) is_informative = entity.stream_type == "informative" event_queue = context.services.get(EventQueue, raise_if_missing=True) if is_informative: - await event_queue.enqueue_event(TaskStatusUpdateEvent( - task_id=message.task_id, - context_id=message.context_id, - status=TaskStatus( - state=TaskState.TASK_STATE_WORKING, - timestamp=datetime.now(timezone.utc), - message=utils.create_message(message.context_id, message.task_id, activity) + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + status=TaskStatus( + state=TaskState.TASK_STATE_WORKING, + timestamp=datetime.now(timezone.utc), + message=utils.create_message( + message.context_id, message.task_id, activity + ), + ), ) - )) + ) else: artifact = utils.activity_to_artifact(activity, entity.stream_id) - await event_queue.enqueue_event(TaskArtifactUpdateEvent( - task_id=message.task_id, - context_id=message.context_id, - artifact=artifact, - append=False, - last_chunk=True, - )) + await event_queue.enqueue_event( + TaskArtifactUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + artifact=artifact, + append=False, + last_chunk=True, + ) + ) async def _on_message_response(self, context: TurnContext, activity: Activity): message = utils.get_incoming_message(context) @@ -208,17 +225,19 @@ async def _on_message_response(self, context: TurnContext, activity: Activity): event_queue = context.services.get(EventQueue, raise_if_missing=True) - await event_queue.enqueue_event(TaskStatusUpdateEvent( - task_id=message.task_id, - context_id=message.context_id, - status=TaskStatus( - state=state, - timestamp=datetime.now(timezone.utc), - message=response + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + status=TaskStatus( + state=state, timestamp=datetime.now(timezone.utc), message=response + ), ) - )) + ) - async def _on_end_of_conversation_response(self, context: TurnContext, activity: Activity): + async def _on_end_of_conversation_response( + self, context: TurnContext, activity: Activity + ): message = utils.get_incoming_message(context) event_queue = context.services.get(EventQueue, raise_if_missing=True) @@ -227,15 +246,17 @@ async def _on_end_of_conversation_response(self, context: TurnContext, activity: activity.value, name="Result", description="Task completion result", - media_type="application/json" + media_type="application/json", + ) + await event_queue.enqueue_event( + TaskArtifactUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + artifact=artifact, + append=False, + last_chunk=True, + ) ) - await event_queue.enqueue_event(TaskArtifactUpdateEvent( - task_id=message.task_id, - context_id=message.context_id, - artifact=artifact, - append=False, - last_chunk=True, - )) task_state: TaskState if activity.code == EndOfConversationCodes.error: @@ -256,15 +277,17 @@ async def _on_end_of_conversation_response(self, context: TurnContext, activity: status_message, ) - await event_queue.enqueue_event(TaskStatusUpdateEvent( - task_id=message.task_id, - context_id=message.context_id, - status=TaskStatus( - state=task_state, - timestamp=datetime.now(timezone.utc), - message=response + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=message.task_id, + context_id=message.context_id, + status=TaskStatus( + state=task_state, + timestamp=datetime.now(timezone.utc), + message=response, + ), ) - )) + ) def _get_agent_card(self) -> AgentCard: @@ -273,7 +296,9 @@ def _get_agent_card(self) -> AgentCard: description=self._agent_card_description, version=self._agent_card_version, security_schemes={ - "jwt": SecurityScheme(http_auth_security_scheme=HTTPAuthSecurityScheme(scheme="bearer")) + "jwt": SecurityScheme( + http_auth_security_scheme=HTTPAuthSecurityScheme(scheme="bearer") + ) }, default_input_modes=["application/json"], default_output_modes=["application/json"], @@ -296,7 +321,10 @@ def _get_agent_card(self) -> AgentCard: ) else: for agent_interface in agent_interfaces: - if agent_interface.protocol in (TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON): + if agent_interface.protocol in ( + TransportProtocol.JSONRPC, + TransportProtocol.HTTP_JSON, + ): agent_card.supported_interfaces.append( protocol_binding=agent_interface.protocol, url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", @@ -308,17 +336,18 @@ def _get_agent_card(self) -> AgentCard: skills = [] if skills: for skill_info in skills: - agent_card.skills.append(AgentSkill( - id=skill_info.id, - name=skill_info.name, - description=skill_info.description, - tags=skill_info.tags, - examples=skill_info.examples, - input_modes=skill_info.input_modes, - output_modes=skill_info.output_modes, - )) + agent_card.skills.append( + AgentSkill( + id=skill_info.id, + name=skill_info.name, + description=skill_info.description, + tags=skill_info.tags, + examples=skill_info.examples, + input_modes=skill_info.input_modes, + output_modes=skill_info.output_modes, + ) + ) def _update_agent_card(self) -> None: agent_card = self._get_agent_card() self._a2a_request_handler.update_agent_card(agent_card) - diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py index 7aaadd2b0..cca978892 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py @@ -10,13 +10,17 @@ ChannelServiceClientFactoryBase, OutboundHostValidator, ) -from microsoft_agents.hosting.core.channel_adapter_protocol import ChannelAdapterProtocol +from microsoft_agents.hosting.core.channel_adapter_protocol import ( + ChannelAdapterProtocol, +) from microsoft_agents.hosting.fastapi import CloudAdapter + class _CloudAdapter(AgentHttpAdapter, ChannelAdapterProtocol): pass + class A2ACloudAdapter(A2AAdapter, AgentHttpAdapter): def __init__(self): @@ -25,10 +29,8 @@ def __init__(self): async def process(self, request: Request, agent: Agent) -> Response | None: adapted_request = FastApiRequestAdapter(request) - + # Process using base implementation http_response: HttpResponse = await self.process_request(adapted_request, agent) - - - return await self.process_request(request, agent) \ No newline at end of file + return await self.process_request(request, agent) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py index e772fd5cd..17aec8681 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py @@ -4,8 +4,9 @@ from microsoft_agents.hosting.core import ClaimsIdentity + @dataclass class AgentRequestContext: request_id: str identity: ClaimsIdentity - event_queue: EventQueue \ No newline at end of file + event_queue: EventQueue diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py index 08afeb2f0..877551406 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py @@ -1 +1 @@ -_CLAIMS_IDENTITY_KEY = "__CLAIMS_IDENTITY_KEY" \ No newline at end of file +_CLAIMS_IDENTITY_KEY = "__CLAIMS_IDENTITY_KEY" diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py index e5b8186de..67146c2f1 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py @@ -1,7 +1,4 @@ from .a2a_agent_executor import A2AAgentExecutor from .a2a_request_handler import A2ARequestHandler -__all__ = [ - "A2AAgentExecutor", - "A2ARequestHandler" -] \ No newline at end of file +__all__ = ["A2AAgentExecutor", "A2ARequestHandler"] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py index 4743a2db5..9052fe4ce 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py @@ -1,9 +1,6 @@ import logging -from a2a.server.agent_execution import ( - AgentExecutor, - RequestContext -) +from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from microsoft_agents.activity import ( @@ -19,6 +16,7 @@ logger = logging.getLogger(__name__) + class A2AAgentExecutor(AgentExecutor): def __init__( @@ -48,5 +46,3 @@ async def cancel( event_queue: EventQueue, ) -> None: pass - - \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py index 4ec2dcd0c..6ca63137e 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py @@ -7,6 +7,7 @@ from .a2a_agent_executor import A2AAgentExecutor + class A2ARequestHandler(DefaultRequestHandlerV2): def __init__( @@ -16,16 +17,11 @@ def __init__( agent_card: AgentCard, ): self._adapter = adapter - super().__init__( - A2AAgentExecutor(self._adapter), - task_store, - agent_card - ) - + super().__init__(A2AAgentExecutor(self._adapter), task_store, agent_card) def update_agent_card(self, agent_card: AgentCard) -> None: """Update the agent card with the provided AgentCard instance. - + :param agent_card: The AgentCard instance to update the handler with. """ - self._agent_card = agent_card \ No newline at end of file + self._agent_card = agent_card diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py index 52a0c8a47..0dcdea2ff 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py @@ -8,6 +8,7 @@ from typing import ( Awaitable, Protocol, + cast, ) from microsoft_agents.hosting.core import AgentApplication, TurnContext @@ -18,14 +19,14 @@ class A2ARouteHandler(Protocol[_StateContra]): - """Protocol for a Teams route handler that receives a :class:`TeamsTurnContext`.""" + """Protocol for a Teams route handler that receives a :class:`A2ATurnContext`.""" def __call__( - self, context: TeamsTurnContext, state: _StateContra, / + self, context: A2ATurnContext, state: _StateContra, / ) -> Awaitable[None]: - """Handle a turn with Teams context. + """Handle a turn with A2A context. - :param context: Teams-aware turn context. + :param context: A2A-aware turn context. :param state: The current turn state. """ ... @@ -45,7 +46,10 @@ def wrap_a2a_route_handler( """ async def __func(context: TurnContext, state: _StateContra) -> None: - a2a_context = A2ATurnContext(context, app) + if not isinstance(context, A2ATurnContext): + a2a_context = A2ATurnContext(context, app) + else: + a2a_context = cast(A2ATurnContext, context) await handler(a2a_context, state) - return __func \ No newline at end of file + return __func diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py index 0638d4ae9..c42f0a3b5 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py @@ -5,4 +5,4 @@ "create_jsonrpc_routes", "create_rest_routes", "SDKServerCallContextBuilder", -] \ No newline at end of file +] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 8dd41ce28..3e2e118ee 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -8,6 +8,7 @@ from .sdk_server_call_context_builder import SDKServerCallContextBuilder + def create_jsonrpc_routes( request_handler: RequestHandler, rpc_url: str, @@ -20,6 +21,7 @@ def create_jsonrpc_routes( enable_v0_3_compat=enable_v0_3_compat, ) + def create_rest_routes( request_handler: RequestHandler, path_prefix: str = "", @@ -30,4 +32,4 @@ def create_rest_routes( context_builder=SDKServerCallContextBuilder(), enable_v0_3_compat=enable_v0_3_compat, path_prefix=path_prefix, - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py index ecedf9a91..4686360ce 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py @@ -9,6 +9,7 @@ from ..constants import _CLAIMS_IDENTITY_KEY + class SDKServerCallContextBuilder(DefaultServerCallContextBuilder): """A default implementation of ServerCallContextBuilder.""" @@ -34,4 +35,4 @@ def build(self, request: Request) -> ServerCallContext: requested_extensions=get_requested_extensions( request.headers.getlist(HTTP_EXTENSION_HEADER) ), - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py index 5039a8a16..adc1a15b4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py @@ -27,7 +27,9 @@ def __init__(self, service_set: _ServiceSet | None = None) -> None: def get(self, key: type[T], raise_if_missing: Literal[True] = True) -> T: ... @overload - def get(self, key: type[T], raise_if_missing: Literal[False] = False) -> T | None: ... + def get( + self, key: type[T], raise_if_missing: Literal[False] = False + ) -> T | None: ... def get(self, key: type[T], raise_if_missing: bool = False) -> T | None: """ Gets a value from the state collection. @@ -48,7 +50,7 @@ def get(self, key: type[T], raise_if_missing: bool = False) -> T | None: if raise_if_missing: raise KeyError(f"Value for key '{key.__name__}' is missing") - + return None def has(self, key: type) -> bool: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter_protocol.py index 8652b2b1a..c17cf83b0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter_protocol.py @@ -25,9 +25,7 @@ async def send_activities( pass @abstractmethod - async def update_activity( - self, context: TurnContext, activity: Activity - ) -> None: + async def update_activity(self, context: TurnContext, activity: Activity) -> None: pass @abstractmethod diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py index 050c6fb9b..961e76411 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -65,7 +65,9 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: # Convert HttpResponse to FastAPI Response return self._to_fastapi_response(http_response) - async def process_request(self, request: HttpRequestProtocol, agent: Agent) -> HttpResponse: + async def process_request( + self, request: HttpRequestProtocol, agent: Agent + ) -> HttpResponse: """Process an incoming HTTP request. Args: From a3a698553f920e063e833ff01c27db6d723d2056 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 18 Sep 2026 15:08:37 -0700 Subject: [PATCH 11/39] Adding licensing headers --- .../microsoft_agents/hosting/a2a/__init__.py | 3 ++ .../hosting/a2a/a2a_agent_extension.py | 4 +- .../hosting/a2a/a2a_client.py | 3 ++ .../hosting/a2a/activity/__init__.py | 3 ++ .../hosting/a2a/activity/utils.py | 3 ++ .../hosting/a2a/adapter/__init__.py | 3 ++ .../hosting/a2a/adapter/a2a_adapter.py | 3 ++ .../hosting/a2a/adapter/a2a_cloud_adapter.py | 3 ++ .../a2a/adapter/agent_request_context.py | 3 ++ .../microsoft_agents/hosting/a2a/constants.py | 3 ++ .../hosting/a2a/protocol/__init__.py | 3 ++ .../hosting/a2a/request_handlers/__init__.py | 3 ++ .../request_handlers/a2a_agent_executor.py | 3 ++ .../request_handlers/a2a_request_handler.py | 3 ++ .../hosting/a2a/route_handlers.py | 5 +-- .../hosting/a2a/server/__init__.py | 3 ++ .../hosting/a2a/server/routes.py | 3 ++ .../server/sdk_server_call_context_builder.py | 3 ++ .../microsoft_agents/hosting/a2a/type_defs.py | 37 ------------------- 19 files changed, 52 insertions(+), 42 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/type_defs.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py index 7e122b812..953d2c922 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py @@ -2,9 +2,12 @@ # Licensed under the MIT License. from .activity import A2AActivity +from .adapter import A2ACloudAdapter, A2AAdapter from .a2a_turn_context import A2ATurnContext __all__ = [ "A2AActivity", "A2ATurnContext", + "A2AAdapter", + "A2ACloudAdapter", ] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py index 8972a9979..5d2a3e6d3 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Callable, Generic, Protocol from re import Pattern @@ -25,7 +28,6 @@ def __call__(self, func: A2ARouteHandler[StateT], /) -> RouteHandler[StateT]: class A2AAgentExtension(Generic[StateT]): def __init__(self, app: AgentApplication[StateT]) -> None: - self._app = app def _wrap_decorator( diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py index c4b8225b9..591c551c7 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from a2a.server.events import EventQueue from a2a.server.agent_execution import RequestContext from a2a.server.tasks import TaskStore diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py index c31e17a49..fa2508d30 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .a2a_activity import A2AActivity __all__ = ["A2AActivity"] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 79b99a6fa..3f9314fd6 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Mapping, cast, Sequence from uuid import uuid4 diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py index 68e37ae85..20f95e536 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .a2a_adapter import A2AAdapter from .a2a_cloud_adapter import A2ACloudAdapter from .agent_request_context import AgentRequestContext diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py index 69ef27c2a..d33a19288 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import logging from datetime import datetime, timezone diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py index cca978892..cdc8e79a6 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from fastapi import Request, Response from microsoft_agents.hosting.fastapi import AgentHttpAdapter diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py index 17aec8681..befdd4f06 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from dataclasses import dataclass from a2a.server.events import EventQueue diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py index 877551406..14eba25f8 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py @@ -1 +1,4 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + _CLAIMS_IDENTITY_KEY = "__CLAIMS_IDENTITY_KEY" diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py index e69de29bb..b9d4a32eb 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py index 67146c2f1..0fb6cb328 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .a2a_agent_executor import A2AAgentExecutor from .a2a_request_handler import A2ARequestHandler diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py index 9052fe4ce..22fd5f1fa 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import logging from a2a.server.agent_execution import AgentExecutor, RequestContext diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py index 6ca63137e..4a889d591 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from a2a.types import AgentCard from a2a.server.tasks import TaskStore diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py index 0dcdea2ff..b19c22da9 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -"""Protocol definitions for Teams-aware route and handoff handlers.""" - from __future__ import annotations from typing import ( @@ -12,10 +10,9 @@ ) from microsoft_agents.hosting.core import AgentApplication, TurnContext -from microsoft_agents.hosting.core.app._type_defs import RouteHandler +from microsoft_agents.hosting.core.app._type_defs import RouteHandler, _StateContra from .a2a_turn_context import A2ATurnContext -from .type_defs import _StateContra class A2ARouteHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py index c42f0a3b5..42eb7cb89 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .routes import create_jsonrpc_routes, create_rest_routes from .sdk_server_call_context_builder import SDKServerCallContextBuilder diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 3e2e118ee..5e13c16ab 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from a2a.server.routes import ( create_jsonrpc_routes as _create_jsonrpc_routes, create_rest_routes as _create_rest_routes, diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py index 4686360ce..4856ef892 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from a2a.server.context import ServerCallContext from a2a.server.routes import DefaultServerCallContextBuilder from a2a.extensions.common import ( diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/type_defs.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/type_defs.py deleted file mode 100644 index bfb29db7c..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/type_defs.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Shared type aliases and protocols used across the Teams hosting sub-package.""" - -from typing import ( - Callable, - TYPE_CHECKING, - TypeVar, - Pattern, - Protocol, -) - -from microsoft_agents.hosting.core import TurnState - -if TYPE_CHECKING: - from .a2a_turn_context import A2ATurnContext - -TeamsRouteSelector = Callable[["A2ATurnContext"], bool] - -StateT = TypeVar("StateT", bound=TurnState) -_StateContra = TypeVar("_StateContra", bound=TurnState, contravariant=True) -RouteHandlerT = TypeVar("RouteHandlerT", bound=Callable) - -CommandSelector = str | Pattern[str] | None - - -class _RouteDecorator(Protocol[RouteHandlerT]): - """Protocol for a decorator that registers *func* as a route and returns it unchanged.""" - - def __call__(self, func: RouteHandlerT) -> RouteHandlerT: - """Register *func* as a route handler and return it. - - :param func: The handler to register. - :return: The same handler, unmodified, so it can be used as a plain callable. - """ - ... From 4ac39ff366fa8e1dcab131d12b39ef0b625095e6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 21 Sep 2026 09:36:59 -0700 Subject: [PATCH 12/39] A2AActivity ChannelId freezing and new A2AHttpAdapter protocol --- .../hosting/a2a/a2a_config.py | 5 +++ .../hosting/a2a/activity/a2a_activity.py | 4 ++ .../hosting/a2a/adapter/__init__.py | 8 +++- .../hosting/a2a/adapter/a2a_adapter.py | 11 +++++- .../hosting/a2a/adapter/a2a_cloud_adapter.py | 39 ------------------- .../hosting/a2a/adapter/a2a_http_adapter.py | 15 +++++++ .../hosting/a2a/server/routes.py | 6 +++ .../hosting/a2a/start_server.py | 0 8 files changed, 46 insertions(+), 42 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_config.py delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_http_adapter.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_config.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_config.py new file mode 100644 index 000000000..203f80705 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_config.py @@ -0,0 +1,5 @@ +class Skill + +class A2AConfig: + + \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index dee4970e5..74b7f8fdc 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -16,6 +16,8 @@ TaskState, ) +from pydantic import Field + from microsoft_agents.activity import ( Activity, ActivityTypes, @@ -37,6 +39,8 @@ class A2AActivity(Activity): """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" + channel_id: ChannelId = Field(default=ChannelId(Channels.a2a), frozen=True) + @staticmethod def from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py index 20f95e536..4113944c5 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py @@ -2,7 +2,11 @@ # Licensed under the MIT License. from .a2a_adapter import A2AAdapter -from .a2a_cloud_adapter import A2ACloudAdapter +from .a2a_http_adapter import A2AHttpAdapter from .agent_request_context import AgentRequestContext -__all__ = ["A2AAdapter", "A2ACloudAdapter", "AgentRequestContext"] +__all__ = [ + "A2AAdapter", + "A2AHttpAdapter", + "AgentRequestContext" +] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py index d33a19288..f6ffd8147 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py @@ -11,6 +11,7 @@ from a2a.types import ( AgentCard, + AgentInterface, SendMessageConfiguration, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, @@ -66,7 +67,15 @@ class A2AAdapter(ChannelAdapter, ChannelAdapterProtocol): - def __init__(self, agent: Agent, task_store: TaskStore | None = None): + def __init__( + self, + agent: Agent, + *, + agent_card_name: str = "A2AAdapter", + agent_card_description: str = "Agents SDK A2A", + agent_card_version: str = "0.0.0", + task_store: TaskStore | None = None + ): """Initializes the A2AAdapter with the given agent and optional task store. :param agent: The agent instance to be used by the adapter. diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py deleted file mode 100644 index cdc8e79a6..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_cloud_adapter.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from fastapi import Request, Response - -from microsoft_agents.hosting.fastapi import AgentHttpAdapter - -from .a2a_adapter import A2AAdapter - -from microsoft_agents.hosting.core import ( - Agent, - Connections, - ChannelServiceClientFactoryBase, - OutboundHostValidator, -) -from microsoft_agents.hosting.core.channel_adapter_protocol import ( - ChannelAdapterProtocol, -) - -from microsoft_agents.hosting.fastapi import CloudAdapter - - -class _CloudAdapter(AgentHttpAdapter, ChannelAdapterProtocol): - pass - - -class A2ACloudAdapter(A2AAdapter, AgentHttpAdapter): - - def __init__(self): - pass - - async def process(self, request: Request, agent: Agent) -> Response | None: - - adapted_request = FastApiRequestAdapter(request) - - # Process using base implementation - http_response: HttpResponse = await self.process_request(adapted_request, agent) - - return await self.process_request(request, agent) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_http_adapter.py new file mode 100644 index 000000000..c7838899d --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_http_adapter.py @@ -0,0 +1,15 @@ +from typing import Protocol + +from a2a.server.agent_execution import RequestContext +from a2a.server.events import EventQueue +from a2a.server.request_handlers import RequestHandler + + +class A2AHttpAdapter(Protocol): + + @property + def a2a_request_handler(self) -> RequestHandler: + ... + + async def execute_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 5e13c16ab..39239280d 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -11,6 +11,8 @@ from .sdk_server_call_context_builder import SDKServerCallContextBuilder +from microsoft_agents.hosting.fastapi import JwtAuthorizationMiddleware + def create_jsonrpc_routes( request_handler: RequestHandler, @@ -36,3 +38,7 @@ def create_rest_routes( enable_v0_3_compat=enable_v0_3_compat, path_prefix=path_prefix, ) + +def add_jwt_middleware(routes: list[BaseRoute]) -> list[BaseRoute]: + # Implement the JWT middleware addition logic here + return routes \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py new file mode 100644 index 000000000..e69de29bb From ca5971b5a030135c3e24abf72622a5d26101ee68 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 21 Sep 2026 09:59:42 -0700 Subject: [PATCH 13/39] A2A extension folder restructure --- .../microsoft_agents/hosting/a2a/__init__.py | 21 ++++++-- .../hosting/a2a/{adapter => }/a2a_adapter.py | 49 ++++++++++++++++-- .../hosting/a2a/a2a_config.py | 5 -- .../hosting/a2a/a2a_turn_context.py | 21 +++++--- .../hosting/a2a/activity/a2a_activity.py | 3 +- .../hosting/a2a/adapter/a2a_http_adapter.py | 15 ------ .../hosting/a2a/protocol/__init__.py | 3 -- .../hosting/a2a/protocol/http.py | 6 --- .../hosting/a2a/protocol/json_rpc.py | 0 .../hosting/a2a/request_handlers/__init__.py | 7 --- .../request_handlers/a2a_agent_executor.py | 51 ------------------- .../{adapter => request_handling}/__init__.py | 10 ++-- .../request_handling/a2a_agent_executor.py | 49 ++++++++++++++++++ .../a2a/request_handling/a2a_http_adapter.py | 25 +++++++++ .../a2a_request_handler.py | 0 .../agent_request_context.py | 0 16 files changed, 158 insertions(+), 107 deletions(-) rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{adapter => }/a2a_adapter.py (85%) delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_config.py delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_http_adapter.py delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/http.py delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/json_rpc.py delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{adapter => request_handling}/__init__.py (56%) create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{request_handlers => request_handling}/a2a_request_handler.py (100%) rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{adapter => request_handling}/agent_request_context.py (100%) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py index 953d2c922..3f72bf15d 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py @@ -2,12 +2,27 @@ # Licensed under the MIT License. from .activity import A2AActivity -from .adapter import A2ACloudAdapter, A2AAdapter +from .request_handling import A2AHttpAdapter, AgentRequestContext +from .server import ( + create_jsonrpc_routes, + create_rest_routes, + SDKServerCallContextBuilder +) +from .a2a_adapter import A2AAdapter +from .a2a_agent_extension import A2AAgentExtension +from .a2a_client import A2AClient from .a2a_turn_context import A2ATurnContext + __all__ = [ "A2AActivity", - "A2ATurnContext", + "A2AHttpAdapter", + "AgentRequestContext", + "create_jsonrpc_routes", + "create_rest_routes", + "SDKServerCallContextBuilder", "A2AAdapter", - "A2ACloudAdapter", + "A2AAgentExtension", + "A2AClient", + "A2ATurnContext" ] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py similarity index 85% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index f6ffd8147..30f4198db 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -56,9 +56,9 @@ HttpRequestProtocol, ) -from ..request_handlers import A2ARequestHandler -from ..activity import utils, A2AActivity -from .agent_request_context import AgentRequestContext +from .request_handling import A2ARequestHandler, AgentRequestContext + +from .activity import utils, A2AActivity from ..constants import _CLAIMS_IDENTITY_KEY @@ -92,6 +92,7 @@ def __init__( @property def a2a_request_handler(self) -> RequestHandler: + """Get the A2A request handler.""" return self._a2a_request_handler async def execute_agent_turn( @@ -99,6 +100,11 @@ async def execute_agent_turn( context: RequestContext, event_queue: EventQueue, ) -> None: + """Execute an agent turn given the request context and event queue. + + :param context: The request context for the agent turn. + :param event_queue: The event queue for the agent turn. + """ if not context.message: raise ValueError("Context message is required.") @@ -137,6 +143,13 @@ def _create_turn_context( oauth_scope: str | None = None, activity: Activity | None = None, ) -> TurnContext: + """Create a turn context for the given claims identity, OAuth scope, and activity. + + :param claims_identity: The claims identity for the turn context. + :param oauth_scope: The OAuth scope for the turn context. + :param activity: The activity for the turn context. + :return: The created turn context. + """ context = TurnContext(self, activity, claims_identity) context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] = oauth_scope context.turn_state[ChannelServiceAdapter.AGENT_IDENTITY_KEY] = ( @@ -151,6 +164,14 @@ async def _process_activity_with_a2a( request_context: RequestContext, event_queue: EventQueue, ) -> InvokeResponse | None: + """Process an activity with the A2A adapter. + + :param identity: The claims identity of the agent. + :param activity: The activity to process. + :param request_context: The request context for the activity. + :param event_queue: The event queue for the activity. + :return: An InvokeResponse if applicable, otherwise None. + """ if activity.channel_id != Channels.a2a: raise ValueError("Activity channel_id must be 'a2a'") @@ -177,6 +198,12 @@ async def _process_activity_with_a2a( async def send_activities( self, context: TurnContext, activities: list[Activity] ) -> list[ResourceResponse]: + """Send activities through the A2A adapter. + + :param context: The turn context for the activities. + :param activities: The list of activities to send. + :return: A list of resource responses. + """ for activity in activities: @@ -199,6 +226,12 @@ async def send_activities( async def _on_streaming_response( self, context: TurnContext, activity: Activity, entity: StreamInfo ): + """Handle a streaming response activity. + + :param context: The turn context for the activity. + :param activity: The activity containing the streaming response. + :param entity: The streaming entity associated with the activity. + """ message = utils.get_incoming_message(context) is_informative = entity.stream_type == "informative" @@ -231,6 +264,11 @@ async def _on_streaming_response( ) async def _on_message_response(self, context: TurnContext, activity: Activity): + """Handle a message response activity. + + :param context: The turn context for the activity. + :param activity: The activity containing the message response. + """ message = utils.get_incoming_message(context) state = utils.get_task_state(activity) response = utils.create_message(message.context_id, message.task_id, activity) @@ -250,6 +288,11 @@ async def _on_message_response(self, context: TurnContext, activity: Activity): async def _on_end_of_conversation_response( self, context: TurnContext, activity: Activity ): + """Handle an end-of-conversation response activity. + + :param context: The turn context for the activity. + :param activity: The activity containing the end-of-conversation response. + """ message = utils.get_incoming_message(context) event_queue = context.services.get(EventQueue, raise_if_missing=True) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_config.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_config.py deleted file mode 100644 index 203f80705..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_config.py +++ /dev/null @@ -1,5 +0,0 @@ -class Skill - -class A2AConfig: - - \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py index 2c29aee1c..80730d556 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -"""Teams-specific turn context wrapper.""" - from __future__ import annotations from typing import cast @@ -16,10 +14,8 @@ ) from .activity import A2AActivity - from .a2a_client import A2AClient - class A2ATurnContext(TurnContext): """A context object for handling A2A-specific turn functionality. @@ -34,6 +30,13 @@ def __init__( activity: Activity | None = None, identity: ClaimsIdentity | None = None, ) -> None: + """Initialize the A2A turn context. + + :param adapter_or_context: The channel service adapter or existing turn context. + :param app: The agent application instance. + :param activity: The activity for the turn context. + :param identity: The claims identity for the turn context. + """ if isinstance(adapter_or_context, TurnContext): super().__init__(adapter_or_context) @@ -44,29 +47,31 @@ def __init__( self._app = app self._turn_state = self.turn_state - self._set_a2a_activity() - self._client = A2AClient(self) - - def _set_a2a_activity(self) -> None: self._activity.__class__ = A2AActivity self._a2a_activity = cast(A2AActivity, self._activity) + self._client = A2AClient(self) @property def client(self) -> A2AClient: + """Get the A2A client associated with this turn context.""" return self._client @property def responded(self) -> bool: + """Check if the turn context has already sent a response.""" return self._original.responded @responded.setter def responded(self, value: bool): + """Set the responded status for the turn context.""" self._original.responded = value @property def streaming_response(self): + """Get the streaming response associated with the turn context.""" return self._original.streaming_response @property def activity(self) -> A2AActivity: + """Get the A2A activity associated with the turn context.""" return self._a2a_activity diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index 74b7f8fdc..4c99a1492 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -64,7 +64,7 @@ def from_message(request_id: str, task_id: str | None, message: Message) -> A2AA return activity def to_message(self, context_id: str, task_id: str, include_entities: bool = True) -> Message: - return utils.activity_to_message(context_id, task_id, self, include_entities) + return utils.create_message(context_id, task_id, self, include_entities) def to_artifact(self, artifact_id: str | None = None, include_entities: bool = True) -> Artifact: return utils.activity_to_artifact(self, artifact_id, include_entities) @@ -90,7 +90,6 @@ def _create_activity( activity = A2AActivity( type=ActivityTypes.message, id=str(uuid4()), - channel_id=ChannelId(Channels.a2a), delivery_mode=DeliveryModes.stream if is_streaming else DeliveryModes.expect_replies, conversation=ConversationAccount(id=conversation_id), recipient=agent if is_ingress else user, diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_http_adapter.py deleted file mode 100644 index c7838899d..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/a2a_http_adapter.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Protocol - -from a2a.server.agent_execution import RequestContext -from a2a.server.events import EventQueue -from a2a.server.request_handlers import RequestHandler - - -class A2AHttpAdapter(Protocol): - - @property - def a2a_request_handler(self) -> RequestHandler: - ... - - async def execute_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: - ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py deleted file mode 100644 index b9d4a32eb..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/http.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/http.py deleted file mode 100644 index cdebdbc1b..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/http.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from a2a.server.request_handlers import RequestHandler - -async def get_task(handler: RequestHandler, \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/json_rpc.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/protocol/json_rpc.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py deleted file mode 100644 index 0fb6cb328..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .a2a_agent_executor import A2AAgentExecutor -from .a2a_request_handler import A2ARequestHandler - -__all__ = ["A2AAgentExecutor", "A2ARequestHandler"] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py deleted file mode 100644 index 22fd5f1fa..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_agent_executor.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import logging - -from a2a.server.agent_execution import AgentExecutor, RequestContext -from a2a.server.events import EventQueue - -from microsoft_agents.activity import ( - Activity, -) -from microsoft_agents.hosting.core import ( - Agent, - ChannelServiceAdapter, - ClaimsIdentity, -) -from ..activity import A2AActivity, utils -from ..adapter import A2AAdapter - -logger = logging.getLogger(__name__) - - -class A2AAgentExecutor(AgentExecutor): - - def __init__( - self, - adapter: A2AAdapter, - ): - self._adapter = adapter - - async def execute( - self, - context: RequestContext, - event_queue: EventQueue, - ) -> None: - - if not context.message: - logger.warning("No message found in the request context. Dropping request.") - return - - await self._adapter.execute_agent_turn( - context, - event_queue, - ) - - async def cancel( - self, - context: RequestContext, - event_queue: EventQueue, - ) -> None: - pass diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py similarity index 56% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py index 4113944c5..2f915496a 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py @@ -1,12 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .a2a_adapter import A2AAdapter +from .a2a_agent_executor import A2AAgentExecutor from .a2a_http_adapter import A2AHttpAdapter +from .a2a_request_handler import A2ARequestHandler from .agent_request_context import AgentRequestContext __all__ = [ - "A2AAdapter", + "A2AAgentExecutor", "A2AHttpAdapter", - "AgentRequestContext" -] + "A2ARequestHandler", + "AgentRequestContext", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py new file mode 100644 index 000000000..75d94d0b1 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue + +from .a2a_http_adapter import A2AHttpAdapter + +logger = logging.getLogger(__name__) + +class A2AAgentExecutor(AgentExecutor): + """Executor for handling A2A agent requests.""" + + def __init__(self, adapter: A2AHttpAdapter): + """Initialize the A2A agent executor. + + :param adapter: The A2A adapter instance to use for executing agent turns. + """ + self._adapter = adapter + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """Execute an A2A agent turn. + + :param context: The request context containing the message and other metadata. + :param event_queue: The event queue for handling events during the turn. + """ + + if not context.message: + logger.warning("No message found in the request context. Dropping request.") + return + + await self._adapter.execute_agent_turn( + context, + event_queue, + ) + + async def cancel( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + """Cancel an ongoing A2A agent turn. + + :param context: The request context containing the message and other metadata. + :param event_queue: The event queue for handling events during the turn. + """ + pass diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py new file mode 100644 index 000000000..61a02b4a9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Protocol + +from a2a.server.agent_execution import RequestContext +from a2a.server.events import EventQueue +from a2a.server.request_handlers import RequestHandler + + +class A2AHttpAdapter(Protocol): + """Protocol for an A2A HTTP adapter.""" + + @property + def a2a_request_handler(self) -> RequestHandler: + """Get the A2A request handler.""" + ... + + async def execute_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + """Execute an agent turn given the request context and event queue. + + :param context: The request context for the agent turn. + :param event_queue: The event queue for the agent turn. + """ + ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py similarity index 100% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handlers/a2a_request_handler.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/agent_request_context.py similarity index 100% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/adapter/agent_request_context.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/agent_request_context.py From da833850ad4734c469cf157171b12c93274caebb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 21 Sep 2026 10:32:43 -0700 Subject: [PATCH 14/39] add_a2a functionality to setup A2A on existing FastAPI app --- .../hosting/a2a/a2a_adapter.py | 8 +++- .../hosting/a2a/activity/a2a_activity.py | 2 +- .../hosting/a2a/server/routes.py | 40 +++++++++++++++--- .../hosting/a2a/start_server.py | 41 +++++++++++++++++++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index 30f4198db..3a6ff8baf 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -56,7 +56,11 @@ HttpRequestProtocol, ) -from .request_handling import A2ARequestHandler, AgentRequestContext +from .request_handling import ( + A2AHttpAdapter, + A2ARequestHandler, + AgentRequestContext, +) from .activity import utils, A2AActivity @@ -65,7 +69,7 @@ logger = logging.getLogger(__name__) -class A2AAdapter(ChannelAdapter, ChannelAdapterProtocol): +class A2AAdapter(A2AHttpAdapter, ChannelAdapter, ChannelAdapterProtocol): def __init__( self, diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index 4c99a1492..84c2f75f8 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -39,7 +39,7 @@ class A2AActivity(Activity): """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" - channel_id: ChannelId = Field(default=ChannelId(Channels.a2a), frozen=True) + channel_id: ChannelId = Field(default_factory=lambda: ChannelId(Channels.a2a), frozen=True) @staticmethod def from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 39239280d..08a8cff3d 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -7,18 +7,25 @@ ) from a2a.server.request_handlers import RequestHandler -from starlette.routing import BaseRoute, Route - -from .sdk_server_call_context_builder import SDKServerCallContextBuilder +from starlette.routing import BaseRoute, Mount, Route from microsoft_agents.hosting.fastapi import JwtAuthorizationMiddleware +from .sdk_server_call_context_builder import SDKServerCallContextBuilder + def create_jsonrpc_routes( request_handler: RequestHandler, rpc_url: str, enable_v0_3_compat: bool = False, ) -> list[Route]: + """Create JSON-RPC routes with the given request handler and RPC URL. + + :param request_handler: The request handler to use for the JSON-RPC routes. + :param rpc_url: The URL for the JSON-RPC endpoint. + :param enable_v0_3_compat: Whether to enable compatibility with version 0.3. + :return: A list of Route objects representing the JSON-RPC routes. + """ return _create_jsonrpc_routes( request_handler, rpc_url, @@ -32,6 +39,13 @@ def create_rest_routes( path_prefix: str = "", enable_v0_3_compat: bool = False, ) -> list[BaseRoute]: + """Create REST routes with the given request handler and path prefix. + + :param request_handler: The request handler to use for the REST routes. + :param path_prefix: The prefix to prepend to all REST route paths. + :param enable_v0_3_compat: Whether to enable compatibility with version 0.3. + :return: A list of BaseRoute objects representing the REST routes. + """ return _create_rest_routes( request_handler, context_builder=SDKServerCallContextBuilder(), @@ -39,6 +53,22 @@ def create_rest_routes( path_prefix=path_prefix, ) -def add_jwt_middleware(routes: list[BaseRoute]) -> list[BaseRoute]: - # Implement the JWT middleware addition logic here +def use_jwt_middleware(routes: list[BaseRoute]) -> None: + """Wrap all routes with JWT authorization middleware. + + :param routes: A list of BaseRoute objects to wrap with JWT authorization middleware. + """ + wrapped: set[int] = set() + + def wrap(route: BaseRoute) -> None: + if isinstance(route, Mount): + for child in route.routes: + wrap(child) + elif isinstance(route, Route) and id(route) not in wrapped: + route.app = JwtAuthorizationMiddleware(route.app) + wrapped.add(id(route)) + + for route in routes: + wrap(route) + return routes \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py index e69de29bb..d8ef93beb 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py @@ -0,0 +1,41 @@ +from fastapi import FastAPI + +from a2a.server.routes import add_a2a_routes_to_fastapi + +from microsoft_agents.hosting.core import Agent + +from .a2a_adapter import A2AAdapter +from .server import create_jsonrpc_routes, create_rest_routes, use_jwt_middleware as _use_jwt_middleware + +def add_a2a( + agent: Agent, + app: FastAPI, + adapter: A2AAdapter | None = None, + *, + default_path: str = "/a2a", + use_jwt_middleware: bool = True, +): + """Add A2A support to the given FastAPI app using the specified agent and adapter. + + :param agent: The agent to use for A2A communication. + :param app: The FastAPI app to which A2A support will be added. + :param adapter: An optional A2AAdapter instance. If not provided, a new one will be created using the agent. + :param default_path: The default URL path for A2A routes. + :param use_jwt_middleware: Whether to use JWT middleware for the routes. + """ + + adapter = adapter or A2AAdapter(agent) + + jsonrpc_routes = create_jsonrpc_routes(adapter.a2a_request_handler, rpc_url=default_path) + rest_routes = create_rest_routes(adapter.a2a_request_handler, path_prefix=default_path) + + if use_jwt_middleware: + _use_jwt_middleware(jsonrpc_routes) + _use_jwt_middleware(rest_routes) + + add_a2a_routes_to_fastapi( + app, + adapter.a2a_request_handler, + jsonrpc_routes=jsonrpc_routes, + rest_routes=rest_routes, + ) \ No newline at end of file From 0bf5a4905d2530b230c17c4e62375c42708aa15c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 21 Sep 2026 10:58:05 -0700 Subject: [PATCH 15/39] Initial draft of A2A sample --- .../a2a/{start_server.py => add_a2a.py} | 0 .../hosting/a2a/server/__init__.py | 7 ++- test_samples/a2a/README.md | 0 test_samples/a2a/agent/__init__.py | 0 test_samples/a2a/agent/agent.py | 0 test_samples/a2a/agent/main.py | 10 +++++ test_samples/a2a/agent/start_server.py | 43 +++++++++++++++++++ test_samples/a2a/requirements.txt | 0 8 files changed, 59 insertions(+), 1 deletion(-) rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{start_server.py => add_a2a.py} (100%) create mode 100644 test_samples/a2a/README.md create mode 100644 test_samples/a2a/agent/__init__.py create mode 100644 test_samples/a2a/agent/agent.py create mode 100644 test_samples/a2a/agent/main.py create mode 100644 test_samples/a2a/agent/start_server.py create mode 100644 test_samples/a2a/requirements.txt diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py similarity index 100% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/start_server.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py index 42eb7cb89..5a58ea164 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py @@ -1,11 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .routes import create_jsonrpc_routes, create_rest_routes +from .routes import ( + create_jsonrpc_routes, + create_rest_routes, + use_jwt_middleware, +) from .sdk_server_call_context_builder import SDKServerCallContextBuilder __all__ = [ "create_jsonrpc_routes", "create_rest_routes", + "use_jwt_middleware", "SDKServerCallContextBuilder", ] diff --git a/test_samples/a2a/README.md b/test_samples/a2a/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/test_samples/a2a/agent/__init__.py b/test_samples/a2a/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_samples/a2a/agent/agent.py b/test_samples/a2a/agent/agent.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_samples/a2a/agent/main.py b/test_samples/a2a/agent/main.py new file mode 100644 index 000000000..9139fe33b --- /dev/null +++ b/test_samples/a2a/agent/main.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/test_samples/a2a/agent/start_server.py b/test_samples/a2a/agent/start_server.py new file mode 100644 index 000000000..f9f180f6e --- /dev/null +++ b/test_samples/a2a/agent/start_server.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +import uvicorn +from fastapi import FastAPI + +from microsoft_agents.hosting.core import ( + AgentApplication, + AgentAuthConfiguration, +) +from microsoft_agents.hosting.fastapi import ( + CloudAdapter, + JwtAuthorizationMiddleware, + start_agent_process +) + +from microsoft_agents.hosting.a2a import add_a2a + + +def start_server( + agent_application: AgentApplication, + auth_configuration: AgentAuthConfiguration, +) -> None: + """Start the FastAPI server with the given agent application and authentication configuration. + + :param agent_application: The agent application to be hosted by the server. + :param auth_configuration: The authentication configuration for the agent. + """ + + app = FastAPI(title="Empty Agent Sample", version="1.0.0") + app.add_middleware(JwtAuthorizationMiddleware) + app.state.agent_configuration = ( + auth_configuration.get_default_connection_configuration() + ) + + # Here we set use_jwt_middleware to False because we have already applied this + # middleware at the app-level above + add_a2a(app, agent_application, use_jwt_middleware=False) + + port = int(environ.get("PORT", 3978)) + uvicorn.run(app, host="127.0.0.1", port=port) \ No newline at end of file diff --git a/test_samples/a2a/requirements.txt b/test_samples/a2a/requirements.txt new file mode 100644 index 000000000..e69de29bb From c7f15a8e2fd17253375327c32a3efbe9e31462b5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 21 Sep 2026 11:08:54 -0700 Subject: [PATCH 16/39] Test sample work --- test_samples/a2a/agent/a2a.py | 5 + test_samples/a2a/agent/start_server.py | 1 - test_samples/a2a/client/__init__.py | 0 test_samples/a2a/client/cli.py | 137 +++++++++++++++++++++++++ test_samples/a2a/requirements.txt | 3 + 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 test_samples/a2a/agent/a2a.py create mode 100644 test_samples/a2a/client/__init__.py create mode 100644 test_samples/a2a/client/cli.py diff --git a/test_samples/a2a/agent/a2a.py b/test_samples/a2a/agent/a2a.py new file mode 100644 index 000000000..47f0c4650 --- /dev/null +++ b/test_samples/a2a/agent/a2a.py @@ -0,0 +1,5 @@ +from microsoft_agents.hosting.a2a import A2AAdapter + +adapter = A2AAdapter( + +) \ No newline at end of file diff --git a/test_samples/a2a/agent/start_server.py b/test_samples/a2a/agent/start_server.py index f9f180f6e..971cb5c32 100644 --- a/test_samples/a2a/agent/start_server.py +++ b/test_samples/a2a/agent/start_server.py @@ -18,7 +18,6 @@ from microsoft_agents.hosting.a2a import add_a2a - def start_server( agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration, diff --git a/test_samples/a2a/client/__init__.py b/test_samples/a2a/client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_samples/a2a/client/cli.py b/test_samples/a2a/client/cli.py new file mode 100644 index 000000000..01746b86b --- /dev/null +++ b/test_samples/a2a/client/cli.py @@ -0,0 +1,137 @@ +# from https://github.com/a2aproject/a2a-python/blob/main/samples/cli.py + +import argparse +import asyncio +import os +import signal +import uuid + +from typing import Any + +import grpc +import httpx + +from a2a.client import A2ACardResolver, ClientConfig, create_client +from a2a.helpers import get_artifact_text, get_message_text +from a2a.helpers.agent_card import display_agent_card +from a2a.types import Message, Part, Role, SendMessageRequest, TaskState + + +async def _handle_stream( + stream: Any, current_task_id: str | None +) -> str | None: + async for event in stream: + if event.HasField('message'): + print('Message:', get_message_text(event.message, delimiter=' ')) + return None + + if not current_task_id: + if event.HasField('task'): + current_task_id = event.task.id + print('--- Task Started ---') + print(f'Task [state={TaskState.Name(event.task.status.state)}]') + else: + raise ValueError(f'Unexpected first event: {event}') + + if event.HasField('status_update'): + state_name = TaskState.Name(event.status_update.status.state) + message_text = ( + ': ' + + get_message_text( + event.status_update.status.message, delimiter=' ' + ) + if event.status_update.status.HasField('message') + else '' + ) + print(f'TaskStatusUpdate [state={state_name}]{message_text}') + if state_name in ( + 'TASK_STATE_COMPLETED', + 'TASK_STATE_FAILED', + 'TASK_STATE_CANCELED', + 'TASK_STATE_REJECTED', + ): + current_task_id = None + print('--- Task Finished ---') + elif event.HasField('artifact_update'): + print( + f'TaskArtifactUpdate [name={event.artifact_update.artifact.name}]:', + get_artifact_text( + event.artifact_update.artifact, delimiter=' ' + ), + ) + return current_task_id + + +async def main() -> None: + """Run the A2A terminal client.""" + parser = argparse.ArgumentParser(description='A2A Terminal Client') + parser.add_argument( + '--url', default='http://127.0.0.1:41241', help='Agent base URL' + ) + parser.add_argument( + '--transport', + default=None, + help='Preferred transport (JSONRPC, HTTP+JSON, GRPC)', + ) + args = parser.parse_args() + + config = ClientConfig( + grpc_channel_factory=grpc.aio.insecure_channel, + ) + if args.transport: + config.supported_protocol_bindings = [args.transport] + + print( + f'Connecting to {args.url} (preferred transport: {args.transport or "Any"})' + ) + + async with httpx.AsyncClient() as httpx_client: + resolver = A2ACardResolver(httpx_client, args.url) + card = await resolver.get_agent_card() + print('\n✓ Agent Card Found:') + display_agent_card(card) + + client = await create_client(card, client_config=config) + + actual_transport = getattr(client, '_transport', client) + print(f' Picked Transport: {actual_transport.__class__.__name__}') + + print('\nConnected! Send a message or type /quit to exit.') + + current_task_id = None + current_context_id = str(uuid.uuid4()) + + while True: + try: + loop = asyncio.get_running_loop() + user_input = await loop.run_in_executor(None, input, 'You: ') + except KeyboardInterrupt: + break + + if user_input.lower() in ('/quit', '/exit'): + break + if not user_input.strip(): + continue + + message = Message( + role=Role.ROLE_USER, + message_id=str(uuid.uuid4()), + parts=[Part(text=user_input)], + task_id=current_task_id, + context_id=current_context_id, + ) + + request = SendMessageRequest(message=message) + + try: + stream = client.send_message(request) + current_task_id = await _handle_stream(stream, current_task_id) + except (httpx.RequestError, grpc.RpcError) as e: + print(f'Error communicating with agent: {e}') + + await client.close() + + +if __name__ == '__main__': + signal.signal(signal.SIGINT, lambda sig, frame: os._exit(0)) + asyncio.run(main()) \ No newline at end of file diff --git a/test_samples/a2a/requirements.txt b/test_samples/a2a/requirements.txt index e69de29bb..d0ba9dfa1 100644 --- a/test_samples/a2a/requirements.txt +++ b/test_samples/a2a/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv +microsoft-agents-hosting-a2a +a2a-sdk[fastapi] \ No newline at end of file From 97e748162364bbfbb1c55d363982aa0c6534c3c2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 21 Sep 2026 12:12:20 -0700 Subject: [PATCH 17/39] Agent card route handling --- .../microsoft_agents/hosting/a2a/__init__.py | 4 +- .../hosting/a2a/a2a_adapter.py | 18 +++--- .../hosting/a2a/a2a_agent_extension.py | 1 - .../hosting/a2a/activity/a2a_activity.py | 63 +++++++++++++++++-- .../hosting/a2a/activity/utils.py | 56 ++++++++++++++++- .../microsoft_agents/hosting/a2a/add_a2a.py | 15 +++-- .../a2a/request_handling/a2a_http_adapter.py | 11 ++++ .../request_handling/a2a_request_handler.py | 4 +- .../hosting/a2a/server/__init__.py | 2 + .../{constants.py => server/_constants.py} | 0 .../hosting/a2a/server/routes.py | 41 ++++++++++-- .../server/sdk_server_call_context_builder.py | 3 +- 12 files changed, 187 insertions(+), 31 deletions(-) rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{constants.py => server/_constants.py} (100%) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py index 3f72bf15d..f3f83bc50 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py @@ -12,6 +12,7 @@ from .a2a_agent_extension import A2AAgentExtension from .a2a_client import A2AClient from .a2a_turn_context import A2ATurnContext +from .add_a2a import add_a2a __all__ = [ @@ -24,5 +25,6 @@ "A2AAdapter", "A2AAgentExtension", "A2AClient", - "A2ATurnContext" + "A2ATurnContext", + "add_a2a", ] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index 3a6ff8baf..f430e7f29 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -7,8 +7,6 @@ from typing import Awaitable, Callable, cast from uuid import uuid4 -from fastapi import Request, Response - from a2a.types import ( AgentCard, AgentInterface, @@ -28,6 +26,7 @@ from a2a.server.agent_execution import RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import TaskStore, InMemoryTaskStore +from a2a.utils.constants import TransportProtocol from microsoft_agents.activity import ( Activity, @@ -64,7 +63,7 @@ from .activity import utils, A2AActivity -from ..constants import _CLAIMS_IDENTITY_KEY +from .server._constants import _CLAIMS_IDENTITY_KEY logger = logging.getLogger(__name__) @@ -87,6 +86,9 @@ def __init__( """ self._agent = agent + self._agent_card_name = agent_card_name + self._agent_card_description = agent_card_description + self._agent_card_version = agent_card_version self._a2a_request_handler = A2ARequestHandler( self, task_store or InMemoryTaskStore(), @@ -384,11 +386,11 @@ def _get_agent_card(self) -> AgentCard: TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON, ): - agent_card.supported_interfaces.append( + agent_card.supported_interfaces.append(AgentInterface( protocol_binding=agent_interface.protocol, url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", protocol_version="1.0", - ) + )) else: logger.info("Unsupported protocol: %s", agent_interface.protocol) @@ -405,8 +407,4 @@ def _get_agent_card(self) -> AgentCard: input_modes=skill_info.input_modes, output_modes=skill_info.output_modes, ) - ) - - def _update_agent_card(self) -> None: - agent_card = self._get_agent_card() - self._a2a_request_handler.update_agent_card(agent_card) + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py index 5d2a3e6d3..a0315e90a 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py @@ -45,7 +45,6 @@ def _wrap_decorator( def __call(func: A2ARouteHandler[StateT]) -> RouteHandler[StateT]: return decorator(wrap_a2a_route_handler(func, self._app)) - return __call def message( diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index 84c2f75f8..18656ed23 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -5,6 +5,8 @@ from __future__ import annotations +import json + from typing import Literal, Mapping, Iterable from uuid import uuid4 @@ -21,6 +23,7 @@ from microsoft_agents.activity import ( Activity, ActivityTypes, + Attachment, Channels, ChannelId, ChannelAccount, @@ -43,6 +46,13 @@ class A2AActivity(Activity): @staticmethod def from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: + """Create an A2AActivity from a Message object. + + :param request_id: The request ID for the activity. + :param task_id: The task ID for the activity. If None, the task ID from the message will be used. + :param message: The Message object to convert. + :return: An A2AActivity object representing the message. + """ if not task_id and not message.task_id: raise ValueError("Task ID is required.") @@ -64,15 +74,29 @@ def from_message(request_id: str, task_id: str | None, message: Message) -> A2AA return activity def to_message(self, context_id: str, task_id: str, include_entities: bool = True) -> Message: + """Convert the activity to a Message object. + + :param context_id: The context ID for the message. + :param task_id: The task ID for the message. + :param include_entities: Whether to include entities in the message. Defaults to True. + :return: A Message object representing the activity. + """ return utils.create_message(context_id, task_id, self, include_entities) def to_artifact(self, artifact_id: str | None = None, include_entities: bool = True) -> Artifact: - return utils.activity_to_artifact(self, artifact_id, include_entities) + """Convert the activity to an Artifact object. - def has_message_content(self) -> bool: - return utils.has_message_content(self) + :param artifact_id: The ID of the artifact. If None, a new ID will be generated. + :param include_entities: Whether to include entities in the artifact. Defaults to True. + :return: An Artifact object representing the activity. + """ + return utils.activity_to_artifact(self, artifact_id, include_entities) def get_task_state(self) -> TaskState: + """Get the current task state of the activity. + + :return: The TaskState of the activity. + """ return utils.get_task_state(self) @staticmethod @@ -82,7 +106,14 @@ def _create_activity( is_ingress: bool, is_streaming: bool ) -> A2AActivity: - """Create an Activity representing an A2A concept.""" + """Create an Activity representing an A2A concept. + + :param conversation_id: The ID of the conversation. + :param parts: The parts to include in the activity. + :param is_ingress: Whether the activity is incoming. + :param is_streaming: Whether the activity is streaming. + :return: An A2AActivity object representing the activity. + """ agent = ChannelAccount(id="assistant", role=RoleTypes.agent) user = ChannelAccount(id=_DEFAULT_USER_ID, role=RoleTypes.user) @@ -97,6 +128,28 @@ def _create_activity( ) for part in parts: - if part.content_case + if part.text: + if not activity.text: + activity.text = part.text + else: + activity.text += part.text + elif part.url: + activity.attachments.append(Attachment( + content_type=part.media_type, + content_url=part.url, + name=part.filename, + )) + elif part.raw: + activity.attachments.append(Attachment( + content_type=part.media_type, + content=part.raw, + name=part.filename, + )) + elif part.data: + activity.attachments.append(Attachment( + content_type="application/json", + content=json.dumps(part.data), + name="A2A DataPart", + )) return activity \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 3f9314fd6..360b2b8f7 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -26,6 +26,13 @@ def activity_to_artifact( activity: Activity, artifact_id: str | None = None, include_entities: bool = True ) -> Artifact: + """Convert an Activity object to an Artifact object. + + :param activity: The activity to convert. + :param artifact_id: The ID of the artifact. If None, a new ID will be generated. + :param include_entities: Whether to include entities in the artifact. Defaults to True. + :return: An Artifact object representing the activity. + """ artifact = Artifact( artifact_id=artifact_id if artifact_id else str(uuid4()), @@ -59,7 +66,7 @@ def activity_to_artifact( artifact.parts.append(part) if include_entities: - for entity in self.entities: + for entity in activity.entities: if not isinstance(entity, StreamInfo): if entity.type not in _SCHEMAS: @@ -78,6 +85,19 @@ def activity_to_artifact( return artifact +def _to_a2a_metadata(entity: Entity, content_type: str) -> dict: + """Convert the given data to A2A metadata. + + :param data: The data to convert. + :param content_type: The content type of the data. + :return: A dictionary representing the A2A metadata. + """ + + return { + "mimeType": content_type, + "type": "object", + # "schema": TODO? + } def create_artifact_from_data( data: dict, @@ -86,6 +106,15 @@ def create_artifact_from_data( media_type: str | None = None, artifact_id: str | None = None, ) -> Artifact | None: + """Create an Artifact object from the given data. + + :param data: The data to include in the artifact. + :param name: The name of the artifact. Defaults to None. + :param description: The description of the artifact. Defaults to None. + :param media_type: The media type of the artifact. Defaults to None. + :param artifact_id: The ID of the artifact. If None, a new ID will be generated. + :return: An Artifact object representing the data, or None if the data is None. + """ if data is None: return None @@ -96,7 +125,7 @@ def create_artifact_from_data( parts=[ Part( data=data, - metadata=_get_a2a_metadata(data, media_type or data.__class__.__name__), + metadata=_to_a2a_metadata(data, media_type or data.__class__.__name__), ) ], ) @@ -108,6 +137,14 @@ def create_message( activity: Activity | None, include_entities: bool = True, ) -> Message: + """Create a Message object. Uses the given activity to populate the message parts. + + :param context_id: The context ID for the message. + :param task_id: The task ID for the message. + :param activity: The activity to convert. + :param include_entities: Whether to include entities in the message. Defaults to True. + :return: A Message object representing the activity. + """ parts: Sequence[Part] | None @@ -127,16 +164,31 @@ def create_message( def has_message_content(activity: Activity) -> bool: + """Check if the activity has message content. + + :param activity: The activity to check. + :return: True if the activity has message content, False otherwise. + """ return bool(activity.text) or bool(activity.attachments) def get_task_state(self) -> TaskState: + """Get the current task state of the activity. + + :return: The TaskState of the activity. + """ if self.input_hint == InputHints.expecting_input: return TaskState.TASK_STATE_INPUT_REQUIRED return TaskState.TASK_STATE_WORKING def get_incoming_message(context: TurnContext) -> Message: + """Get the incoming message from the turn context. + + :param context: The turn context containing the activity. + :return: The Message object from the channel data. + :raises TypeError: If the channel data is not of type Message. + """ data = context.activity.channel_data if isinstance(data, Message): return cast(Message, data) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index d8ef93beb..c2c5efc05 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -5,11 +5,16 @@ from microsoft_agents.hosting.core import Agent from .a2a_adapter import A2AAdapter -from .server import create_jsonrpc_routes, create_rest_routes, use_jwt_middleware as _use_jwt_middleware +from .server import ( + create_jsonrpc_routes, + create_rest_routes, + create_agent_card_routes, + use_jwt_middleware as _use_jwt_middleware +) def add_a2a( - agent: Agent, app: FastAPI, + agent: Agent, adapter: A2AAdapter | None = None, *, default_path: str = "/a2a", @@ -17,8 +22,8 @@ def add_a2a( ): """Add A2A support to the given FastAPI app using the specified agent and adapter. - :param agent: The agent to use for A2A communication. :param app: The FastAPI app to which A2A support will be added. + :param agent: The agent to use for A2A communication. :param adapter: An optional A2AAdapter instance. If not provided, a new one will be created using the agent. :param default_path: The default URL path for A2A routes. :param use_jwt_middleware: Whether to use JWT middleware for the routes. @@ -28,14 +33,16 @@ def add_a2a( jsonrpc_routes = create_jsonrpc_routes(adapter.a2a_request_handler, rpc_url=default_path) rest_routes = create_rest_routes(adapter.a2a_request_handler, path_prefix=default_path) + agent_card_routes = create_agent_card_routes(adapter.get_agent_card) if use_jwt_middleware: _use_jwt_middleware(jsonrpc_routes) _use_jwt_middleware(rest_routes) + _use_jwt_middleware(agent_card_routes) add_a2a_routes_to_fastapi( app, - adapter.a2a_request_handler, + agent_card_routes=agent_card_routes, jsonrpc_routes=jsonrpc_routes, rest_routes=rest_routes, ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py index 61a02b4a9..f3d2c84bb 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py @@ -6,6 +6,9 @@ from a2a.server.agent_execution import RequestContext from a2a.server.events import EventQueue from a2a.server.request_handlers import RequestHandler +from a2a.types import AgentCard + +from microsoft_agents.hosting.core import HttpRequestProtocol class A2AHttpAdapter(Protocol): @@ -22,4 +25,12 @@ async def execute_agent_turn(self, context: RequestContext, event_queue: EventQu :param context: The request context for the agent turn. :param event_queue: The event queue for the agent turn. """ + ... + + async def get_agent_card(self, request: HttpRequestProtocol) -> AgentCard: + """Process a request for the agent card. + + :param request: The HTTP request for the agent card. + :return: The agent card for the agent. + """ ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py index 4a889d591..89501aa93 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py @@ -6,7 +6,7 @@ from a2a.server.tasks import TaskStore from a2a.server.request_handlers import DefaultRequestHandlerV2 -from ..adapter import A2AAdapter +from .a2a_http_adapter import A2AHttpAdapter from .a2a_agent_executor import A2AAgentExecutor @@ -15,7 +15,7 @@ class A2ARequestHandler(DefaultRequestHandlerV2): def __init__( self, - adapter: A2AAdapter, + adapter: A2AHttpAdapter, task_store: TaskStore, agent_card: AgentCard, ): diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py index 5a58ea164..79aad513e 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/__init__.py @@ -4,6 +4,7 @@ from .routes import ( create_jsonrpc_routes, create_rest_routes, + create_agent_card_routes, use_jwt_middleware, ) from .sdk_server_call_context_builder import SDKServerCallContextBuilder @@ -11,6 +12,7 @@ __all__ = [ "create_jsonrpc_routes", "create_rest_routes", + "create_agent_card_routes", "use_jwt_middleware", "SDKServerCallContextBuilder", ] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_constants.py similarity index 100% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/constants.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_constants.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 08a8cff3d..1c3e0f044 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -1,15 +1,24 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import Awaitable, Callable, Sequence + from a2a.server.routes import ( create_jsonrpc_routes as _create_jsonrpc_routes, create_rest_routes as _create_rest_routes, ) from a2a.server.request_handlers import RequestHandler +from a2a.server.request_handlers.response_helpers import agent_card_to_dict +from a2a.types import AgentCard +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH +from starlette.requests import Request +from starlette.responses import JSONResponse, Response from starlette.routing import BaseRoute, Mount, Route +from microsoft_agents.hosting.core import HttpRequestProtocol from microsoft_agents.hosting.fastapi import JwtAuthorizationMiddleware +from microsoft_agents.hosting.fastapi._fastapi_request_adapter import FastApiRequestAdapter from .sdk_server_call_context_builder import SDKServerCallContextBuilder @@ -53,7 +62,32 @@ def create_rest_routes( path_prefix=path_prefix, ) -def use_jwt_middleware(routes: list[BaseRoute]) -> None: +def create_agent_card_routes( + get_agent_card: Callable[[HttpRequestProtocol], Awaitable[AgentCard]], + card_url: str = AGENT_CARD_WELL_KNOWN_PATH, +) -> list[Route]: + """Create routes for serving the agent card. + + :param get_agent_card: A callable that takes an HttpRequestProtocol and returns an AgentCard. + :param card_url: The URL path for the agent card endpoint. + :param enable_v0_3_compat: Whether to enable compatibility with version 0.3. + :return: A list of Route objects representing the agent card routes. + """ + + async def _get_agent_card(request: Request) -> Response: + """Retruns the public AgentCard describing this agent's capabilities, supported transports, and skills.""" + card = await get_agent_card(FastApiRequestAdapter(request)) + return JSONResponse(agent_card_to_dict(card)) + + return [ + Route( + path=card_url, + endpoint=_get_agent_card, + methods=['GET'] + ) + ] + +def use_jwt_middleware(routes: Sequence[BaseRoute]) -> None: """Wrap all routes with JWT authorization middleware. :param routes: A list of BaseRoute objects to wrap with JWT authorization middleware. @@ -61,6 +95,7 @@ def use_jwt_middleware(routes: list[BaseRoute]) -> None: wrapped: set[int] = set() def wrap(route: BaseRoute) -> None: + """Wrap the given route with JWT authorization middleware if it hasn't been wrapped already.""" if isinstance(route, Mount): for child in route.routes: wrap(child) @@ -69,6 +104,4 @@ def wrap(route: BaseRoute) -> None: wrapped.add(id(route)) for route in routes: - wrap(route) - - return routes \ No newline at end of file + wrap(route) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py index 4856ef892..dc3b29f98 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py @@ -10,8 +10,7 @@ from starlette.requests import Request -from ..constants import _CLAIMS_IDENTITY_KEY - +from ._constants import _CLAIMS_IDENTITY_KEY class SDKServerCallContextBuilder(DefaultServerCallContextBuilder): """A default implementation of ServerCallContextBuilder.""" From eff47f942a9c403aad121b60f2e9f034fcc913ff Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 21 Sep 2026 15:05:16 -0700 Subject: [PATCH 18/39] Improvements for hosting-level route registration and improvements to get_agent_card design --- .../hosting/a2a/a2a_adapter.py | 97 ++++++++++++++----- .../hosting/a2a/a2a_agent_extension.py | 19 ++-- .../hosting/a2a/a2a_client.py | 8 ++ .../hosting/a2a/activity/utils.py | 9 +- .../microsoft_agents/hosting/a2a/add_a2a.py | 52 ++++++++-- .../a2a/request_handling/a2a_http_adapter.py | 19 +++- .../hosting/a2a/server/routes.py | 14 ++- .../server/sdk_server_call_context_builder.py | 4 +- .../aiohttp/_aiohttp_request_adapter.py | 8 +- .../core/http/_http_request_protocol.py | 5 + .../fastapi/_fastapi_request_adapter.py | 8 +- 11 files changed, 193 insertions(+), 50 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index f430e7f29..b23af90a7 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -3,8 +3,10 @@ import logging + from datetime import datetime, timezone from typing import Awaitable, Callable, cast +from urllib.parse import urlsplit from uuid import uuid4 from a2a.types import ( @@ -21,12 +23,13 @@ AgentSkill, HTTPAuthSecurityScheme, SecurityScheme, + Skill, ) from a2a.server.request_handlers import RequestHandler from a2a.server.agent_execution import RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import TaskStore, InMemoryTaskStore -from a2a.utils.constants import TransportProtocol +from a2a.utils.constants import TransportProtocol, AGENT_CARD_WELL_KNOWN_PATH from microsoft_agents.activity import ( Activity, @@ -77,6 +80,8 @@ def __init__( agent_card_name: str = "A2AAdapter", agent_card_description: str = "Agents SDK A2A", agent_card_version: str = "0.0.0", + agent_interfaces: list[AgentInterface] | None = None, + skills: list[Skill] | None = None, task_store: TaskStore | None = None ): """Initializes the A2AAdapter with the given agent and optional task store. @@ -89,13 +94,33 @@ def __init__( self._agent_card_name = agent_card_name self._agent_card_description = agent_card_description self._agent_card_version = agent_card_version + self._task_store = task_store or InMemoryTaskStore() + + self._skills: list[Skill] = skills or [] + self._agent_interfaces: list[AgentInterface] = agent_interfaces or [ + AgentInterface( + url="/a2a", + protocol_binding=TransportProtocol.JSONRPC, + ) + ] + self._a2a_request_handler = A2ARequestHandler( self, - task_store or InMemoryTaskStore(), - agent_card=self._get_agent_card(), + task_store=self._task_store, + agent_card=self._get_basic_agent_card(), ) self._context_map: dict[str, AgentRequestContext] = {} + @property + def skills(self) -> list[Skill]: + """Get the list of skills associated with the adapter.""" + return self._skills + + @property + def agent_interfaces(self) -> list[AgentInterface]: + """Get the list of agent interfaces associated with the adapter.""" + return self._agent_interfaces + @property def a2a_request_handler(self) -> RequestHandler: """Get the A2A request handler.""" @@ -136,12 +161,15 @@ async def execute_agent_turn( event_queue=event_queue, ) - await self._process_activity_with_a2a( - identity, - activity, - context, - event_queue, - ) + try: + await self._process_activity_with_a2a( + identity, + activity, + context, + event_queue, + ) + finally: + del self._context_map[request_id] def _create_turn_context( self, @@ -198,6 +226,7 @@ async def _process_activity_with_a2a( ) context.services.set(RequestContext, request_context) context.services.set(EventQueue, event_queue) + context.services.set(TaskStore, self._task_store) await self.run_pipeline(context, self._agent.on_turn) @@ -327,7 +356,7 @@ async def _on_end_of_conversation_response( else: task_state = TaskState.TASK_STATE_COMPLETED - status_message: Activity + status_message: Activity | None = None if utils.has_message_content(activity): status_message = activity.model_copy() status_message.value = None @@ -350,9 +379,9 @@ async def _on_end_of_conversation_response( ) ) - def _get_agent_card(self) -> AgentCard: - - agent_card = AgentCard( + def _get_basic_agent_card(self) -> AgentCard: + """Get the basic agent card with default settings.""" + return AgentCard( name=self._agent_card_name, description=self._agent_card_description, version=self._agent_card_version, @@ -371,32 +400,42 @@ def _get_agent_card(self) -> AgentCard: supported_interfaces=[], ) - agent_interfaces = [] - if not agent_interfaces: + async def get_agenst_card(self, request: HttpRequestProtocol, path_prefix: str) -> AgentCard: + """Get the agent card for the current agent, potentially customized based on the request. + + Set as asynchronous because in some implementations, fetching or customizing the agent card might involve I/O operations, such as querying a database or an external service. + + :param request: The HTTP request object conforming to HttpRequestProtocol. + :return: An AgentCard instance representing the agent's capabilities. + """ + agent_card = self._get_basic_agent_card() + + url_parts = urlsplit(request.url) + + if not self._agent_interfaces: agent_card.supported_interfaces.append( AgentInterface( protocol_binding=TransportProtocol.JSONRPC, - url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", + url=f"{url_parts.scheme}://{url_parts.hostname}{path_prefix}/", protocol_version="1.0", ) ) else: - for agent_interface in agent_interfaces: - if agent_interface.protocol in ( + for agent_interface in self._agent_interfaces: + if agent_interface.protocol_binding in ( TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON, ): agent_card.supported_interfaces.append(AgentInterface( - protocol_binding=agent_interface.protocol, - url=f"{request.url.scheme}://{request.url.hostname}{path_prefix}/", + protocol_binding=agent_interface.protocol_binding, + url=agent_interface.url, protocol_version="1.0", )) else: - logger.info("Unsupported protocol: %s", agent_interface.protocol) + logger.info("Unsupported protocol: %s", agent_interface.protocol_binding) - skills = [] - if skills: - for skill_info in skills: + if self._skills: + for skill_info in self._skills: agent_card.skills.append( AgentSkill( id=skill_info.id, @@ -407,4 +446,12 @@ def _get_agent_card(self) -> AgentCard: input_modes=skill_info.input_modes, output_modes=skill_info.output_modes, ) - ) \ No newline at end of file + ) + return agent_card + + + async def update_activity(self, context: TurnContext, activity: Activity) -> None: + raise NotImplementedError("A2AAdapter.update_activity is not implemented.") + + async def delete_activity(self, context: TurnContext, activity_id: str) -> None: + raise NotImplementedError("A2AAdapter.delete_activity is not implemented.") \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py index a0315e90a..94feb1340 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py @@ -17,30 +17,35 @@ class _AppRouteDecorator(Protocol[StateT]): """Protocol for a decorator returned by :class:`TeamsAgentExtension` route methods.""" def __call__(self, func: A2ARouteHandler[StateT], /) -> RouteHandler[StateT]: - """Register *func* as a Teams route handler. + """Register *func* as an A2A route handler. - :param func: Teams-aware handler to register. + :param func: A2A-aware handler to register. :return: The wrapped core route handler. """ ... class A2AAgentExtension(Generic[StateT]): + """Extension for adding A2A-specific routes to an AgentApplication.""" def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialize the A2AAgentExtension with the given AgentApplication. + + :param app: The AgentApplication instance to extend. + """ self._app = app def _wrap_decorator( self, decorator: Callable[[RouteHandler[StateT]], RouteHandler[StateT]] ) -> Callable[[A2ARouteHandler[StateT]], RouteHandler[StateT]]: - """Wrap a core route decorator so it accepts a :class:`TeamsRouteHandler`. + """Wrap a core route decorator so it accepts a :class:`A2ARouteHandler`. - The returned decorator converts the Teams handler via - :func:`wrap_teams_route_handler` before passing it to *decorator*, keeping the - Teams context upgrade transparent to callers. + The returned decorator converts the A2A handler via + :func:`wrap_a2a_route_handler` before passing it to *decorator*, keeping the + A2A context upgrade transparent to callers. :param decorator: A core route decorator from :class:`AgentApplication`. - :return: A decorator that accepts and registers a :class:`TeamsRouteHandler`. + :return: A decorator that accepts and registers a :class:`A2ARouteHandler`. """ def __call(func: A2ARouteHandler[StateT]) -> RouteHandler[StateT]: diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py index 591c551c7..a22c02bfd 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py @@ -9,8 +9,13 @@ class A2AClient: + """A client for interacting with the A2A services within a TurnContext.""" def __init__(self, context: TurnContext): + """Initialize the A2AClient with the given TurnContext. + + :param context: The TurnContext containing the required services. + """ event_queue = context.services.get(EventQueue) request_context = context.services.get(RequestContext) @@ -25,12 +30,15 @@ def __init__(self, context: TurnContext): @property def event_queue(self) -> EventQueue: + """Get the event queue associated with this client.""" return self._event_queue @property def request_context(self) -> RequestContext: + """Get the request context associated with this client.""" return self._request_context @property def task_store(self) -> TaskStore: + """Get the task store associated with this client.""" return self._task_store diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 360b2b8f7..0637fbe03 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -6,8 +6,9 @@ from a2a.types import ( Artifact, - Part, Message, + Part, + Role, TaskState, ) @@ -159,7 +160,7 @@ def create_message( context_id=context_id, message_id=str(uuid4()), parts=parts, - role=RoleTypes.agent, + role=Role.ROLE_AGENT, ) @@ -172,12 +173,12 @@ def has_message_content(activity: Activity) -> bool: return bool(activity.text) or bool(activity.attachments) -def get_task_state(self) -> TaskState: +def get_task_state(activity: Activity) -> TaskState: """Get the current task state of the activity. :return: The TaskState of the activity. """ - if self.input_hint == InputHints.expecting_input: + if activity.input_hint == InputHints.expecting_input: return TaskState.TASK_STATE_INPUT_REQUIRED return TaskState.TASK_STATE_WORKING diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index c2c5efc05..6232108b9 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -1,6 +1,9 @@ from fastapi import FastAPI +from starlette.routing import BaseRoute, Route from a2a.server.routes import add_a2a_routes_to_fastapi +from a2a.types import AgentInterface +from a2a.utils.constants import TransportProtocol, AGENT_CARD_WELL_KNOWN_PATH from microsoft_agents.hosting.core import Agent @@ -12,12 +15,38 @@ use_jwt_middleware as _use_jwt_middleware ) +def _create_jsonrpc_interface_routes( + adapter: A2AAdapter, + interface: AgentInterface, +) -> tuple[list[Route], list[BaseRoute]]: + """Create JSON-RPC interface routes for the given agent interface. + + :param adapter: The A2AAdapter instance used to handle requests. + :param interface: The agent interface to add routes for. + """ + + jsonrpc_routes = create_jsonrpc_routes(adapter.a2a_request_handler, rpc_url=interface.url) + agent_card_routes = create_agent_card_routes(adapter.get_agent_card, f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}") + return jsonrpc_routes, agent_card_routes + +def _create_http_interface_routes( + adapter: A2AAdapter, + interface: AgentInterface, +) -> tuple[list[BaseRoute], list[BaseRoute]]: + """Create HTTP interface routes for the given agent interface. + + :param adapter: The A2AAdapter instance used to handle requests. + :param interface: The agent interface to add routes for. + """ + http_routes = create_rest_routes(adapter.a2a_request_handler, path_prefix=interface.url) + agent_card_routes = create_agent_card_routes(adapter.get_agent_card, f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}") + return http_routes, agent_card_routes + def add_a2a( app: FastAPI, agent: Agent, adapter: A2AAdapter | None = None, *, - default_path: str = "/a2a", use_jwt_middleware: bool = True, ): """Add A2A support to the given FastAPI app using the specified agent and adapter. @@ -25,20 +54,31 @@ def add_a2a( :param app: The FastAPI app to which A2A support will be added. :param agent: The agent to use for A2A communication. :param adapter: An optional A2AAdapter instance. If not provided, a new one will be created using the agent. - :param default_path: The default URL path for A2A routes. :param use_jwt_middleware: Whether to use JWT middleware for the routes. """ adapter = adapter or A2AAdapter(agent) - jsonrpc_routes = create_jsonrpc_routes(adapter.a2a_request_handler, rpc_url=default_path) - rest_routes = create_rest_routes(adapter.a2a_request_handler, path_prefix=default_path) - agent_card_routes = create_agent_card_routes(adapter.get_agent_card) + agent_card_routes: list[BaseRoute] = [] + jsonrpc_routes: list[BaseRoute] = [] + rest_routes: list[BaseRoute] = [] + + interfaces = adapter.agent_interfaces + + for interface in interfaces: + if interface.protocol_binding == TransportProtocol.JSONRPC: + _jsonrpc_routes, _agent_card_routes = _create_jsonrpc_interface_routes(adapter, interface) + jsonrpc_routes.extend(_jsonrpc_routes) + agent_card_routes.extend(_agent_card_routes) + elif interface.protocol_binding == TransportProtocol.HTTP_JSON: + _http_routes, _agent_card_routes = _create_http_interface_routes(adapter, interface) + rest_routes.extend(_http_routes) + agent_card_routes.extend(_agent_card_routes) if use_jwt_middleware: + _use_jwt_middleware(agent_card_routes) _use_jwt_middleware(jsonrpc_routes) _use_jwt_middleware(rest_routes) - _use_jwt_middleware(agent_card_routes) add_a2a_routes_to_fastapi( app, diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py index f3d2c84bb..f6b9092b6 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py @@ -6,7 +6,11 @@ from a2a.server.agent_execution import RequestContext from a2a.server.events import EventQueue from a2a.server.request_handlers import RequestHandler -from a2a.types import AgentCard +from a2a.types import ( + AgentCard, + AgentInterface, + Skill, +) from microsoft_agents.hosting.core import HttpRequestProtocol @@ -14,6 +18,16 @@ class A2AHttpAdapter(Protocol): """Protocol for an A2A HTTP adapter.""" + @property + def agent_interfaces(self) -> list[AgentInterface]: + """Get the list of agent interfaces.""" + ... + + @property + def skills(self) -> list[Skill]: + """Get the list of skills.""" + ... + @property def a2a_request_handler(self) -> RequestHandler: """Get the A2A request handler.""" @@ -27,10 +41,11 @@ async def execute_agent_turn(self, context: RequestContext, event_queue: EventQu """ ... - async def get_agent_card(self, request: HttpRequestProtocol) -> AgentCard: + async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) -> AgentCard: """Process a request for the agent card. :param request: The HTTP request for the agent card. + :param path_prefix: The path prefix to be used in the agent card URL. :return: The agent card for the agent. """ ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 1c3e0f044..a6cb8e025 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -63,9 +63,9 @@ def create_rest_routes( ) def create_agent_card_routes( - get_agent_card: Callable[[HttpRequestProtocol], Awaitable[AgentCard]], + get_agent_card: Callable[[HttpRequestProtocol, str], Awaitable[AgentCard]], card_url: str = AGENT_CARD_WELL_KNOWN_PATH, -) -> list[Route]: +) -> list[BaseRoute]: """Create routes for serving the agent card. :param get_agent_card: A callable that takes an HttpRequestProtocol and returns an AgentCard. @@ -74,9 +74,17 @@ def create_agent_card_routes( :return: A list of Route objects representing the agent card routes. """ + prefix = card_url + try: + i = card_url.index(AGENT_CARD_WELL_KNOWN_PATH) + prefix = card_url[:i] + except ValueError: + # not found + pass + async def _get_agent_card(request: Request) -> Response: """Retruns the public AgentCard describing this agent's capabilities, supported transports, and skills.""" - card = await get_agent_card(FastApiRequestAdapter(request)) + card = await get_agent_card(FastApiRequestAdapter(request), prefix) return JSONResponse(agent_card_to_dict(card)) return [ diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py index dc3b29f98..cbf8af33e 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py @@ -29,7 +29,9 @@ def build(self, request: Request) -> ServerCallContext: if "auth" in request.scope: state["auth"] = request.auth state["headers"] = dict(request.headers) - state[_CLAIMS_IDENTITY_KEY] = request.state.claims_identity + + if getattr(request.state, "claims_identity", None) is not None: + state[_CLAIMS_IDENTITY_KEY] = request.state.claims_identity return ServerCallContext( user=self.build_user(request), diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/_aiohttp_request_adapter.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/_aiohttp_request_adapter.py index 97362cee4..4f6b45761 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/_aiohttp_request_adapter.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/_aiohttp_request_adapter.py @@ -3,8 +3,10 @@ from aiohttp.web import Request +from microsoft_agents.hosting.core import HttpRequestProtocol -class AiohttpRequestAdapter: + +class AiohttpRequestAdapter(HttpRequestProtocol): """Adapter to make aiohttp Request compatible with HttpRequestProtocol.""" def __init__(self, request: Request): @@ -18,6 +20,10 @@ def method(self) -> str: def headers(self): return self._request.headers + @property + def url(self) -> str: + return str(self._request.url) + async def json(self): return await self._request.json() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_request_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_request_protocol.py index 0170c59f7..3e2513619 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_request_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_request_protocol.py @@ -23,6 +23,11 @@ def headers(self) -> Mapping[str, str]: """Request headers.""" ... + @property + def url(self) -> str: + """Request URL as a string.""" + ... + async def json(self) -> dict[str, Any]: """Parse request body as JSON.""" ... diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_fastapi_request_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_fastapi_request_adapter.py index d733a4502..ef0c1805d 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_fastapi_request_adapter.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_fastapi_request_adapter.py @@ -3,8 +3,10 @@ from fastapi import Request +from microsoft_agents.hosting.core import HttpRequestProtocol -class FastApiRequestAdapter: + +class FastApiRequestAdapter(HttpRequestProtocol): """Adapter to make FastAPI Request compatible with HttpRequestProtocol.""" def __init__(self, request: Request): @@ -18,6 +20,10 @@ def method(self) -> str: def headers(self): return self._request.headers + @property + def url(self) -> str: + return str(self._request.url) + async def json(self): return await self._request.json() From 1d50baa34d9315e7d5ee7541a35949667f1e6718 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 22 Sep 2026 08:18:23 -0700 Subject: [PATCH 19/39] Cleaning up imports and docstrings --- .../hosting/a2a/a2a_adapter.py | 25 +++++++++---------- .../hosting/a2a/activity/a2a_activity.py | 7 ++---- .../hosting/a2a/activity/utils.py | 1 - .../microsoft_agents/hosting/a2a/add_a2a.py | 3 +++ .../a2a/request_handling/a2a_http_adapter.py | 4 +-- .../hosting/a2a/route_handlers.py | 6 ++--- 6 files changed, 22 insertions(+), 24 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index b23af90a7..33f05fc0f 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -3,41 +3,33 @@ import logging - from datetime import datetime, timezone -from typing import Awaitable, Callable, cast from urllib.parse import urlsplit from uuid import uuid4 from a2a.types import ( AgentCard, AgentInterface, - SendMessageConfiguration, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TaskStatus, TaskState, AgentCapabilities, - AgentCard, - AgentInterface, AgentSkill, HTTPAuthSecurityScheme, SecurityScheme, - Skill, ) from a2a.server.request_handlers import RequestHandler from a2a.server.agent_execution import RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import TaskStore, InMemoryTaskStore -from a2a.utils.constants import TransportProtocol, AGENT_CARD_WELL_KNOWN_PATH +from a2a.utils.constants import TransportProtocol from microsoft_agents.activity import ( Activity, ActivityTypes, CallerIdConstants, Channels, - ConversationParameters, - ConversationReference, EndOfConversationCodes, InvokeResponse, ResourceResponse, @@ -72,6 +64,7 @@ class A2AAdapter(A2AHttpAdapter, ChannelAdapter, ChannelAdapterProtocol): + """Adapter for handling Agent-to-Agent (A2A) communication within the Microsoft Agents framework.""" def __init__( self, @@ -81,12 +74,17 @@ def __init__( agent_card_description: str = "Agents SDK A2A", agent_card_version: str = "0.0.0", agent_interfaces: list[AgentInterface] | None = None, - skills: list[Skill] | None = None, + skills: list[AgentSkill] | None = None, task_store: TaskStore | None = None ): """Initializes the A2AAdapter with the given agent and optional task store. :param agent: The agent instance to be used by the adapter. + :param agent_card_name: The name of the agent card. + :param agent_card_description: The description of the agent card. + :param agent_card_version: The version of the agent card. + :param agent_interfaces: The list of agent interfaces associated with the adapter. + :param skills: The list of skills associated with the adapter. :param task_store: Optional task store for managing tasks. If not provided, an in-memory task store will be used. """ @@ -96,7 +94,7 @@ def __init__( self._agent_card_version = agent_card_version self._task_store = task_store or InMemoryTaskStore() - self._skills: list[Skill] = skills or [] + self._skills: list[AgentSkill] = skills or [] self._agent_interfaces: list[AgentInterface] = agent_interfaces or [ AgentInterface( url="/a2a", @@ -112,7 +110,7 @@ def __init__( self._context_map: dict[str, AgentRequestContext] = {} @property - def skills(self) -> list[Skill]: + def skills(self) -> list[AgentSkill]: """Get the list of skills associated with the adapter.""" return self._skills @@ -400,12 +398,13 @@ def _get_basic_agent_card(self) -> AgentCard: supported_interfaces=[], ) - async def get_agenst_card(self, request: HttpRequestProtocol, path_prefix: str) -> AgentCard: + async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) -> AgentCard: """Get the agent card for the current agent, potentially customized based on the request. Set as asynchronous because in some implementations, fetching or customizing the agent card might involve I/O operations, such as querying a database or an external service. :param request: The HTTP request object conforming to HttpRequestProtocol. + :param path_prefix: The prefix to be used for constructing the agent interface URL. :return: An AgentCard instance representing the agent's capabilities. """ agent_card = self._get_basic_agent_card() diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index 18656ed23..a44fa50d9 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -7,14 +7,13 @@ import json -from typing import Literal, Mapping, Iterable +from typing import Iterable from uuid import uuid4 from a2a.types import ( Artifact, Message, Part, - Task, TaskState, ) @@ -30,14 +29,12 @@ ConversationAccount, DeliveryModes, RoleTypes, - InputHints, - StreamInfo, ) from . import utils _DEFAULT_USER_ID = "unknown" -_ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" +# _ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" class A2AActivity(Activity): """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 0637fbe03..857a459b6 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -15,7 +15,6 @@ from microsoft_agents.activity import ( Activity, InputHints, - RoleTypes, StreamInfo, ) from microsoft_agents.hosting.core import TurnContext diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index 6232108b9..fd5086d22 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from fastapi import FastAPI from starlette.routing import BaseRoute, Route diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py index f6b9092b6..36ee41f0e 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py @@ -9,7 +9,7 @@ from a2a.types import ( AgentCard, AgentInterface, - Skill, + AgentSkill, ) from microsoft_agents.hosting.core import HttpRequestProtocol @@ -24,7 +24,7 @@ def agent_interfaces(self) -> list[AgentInterface]: ... @property - def skills(self) -> list[Skill]: + def skills(self) -> list[AgentSkill]: """Get the list of skills.""" ... diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py index b19c22da9..dea130e80 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py @@ -16,7 +16,7 @@ class A2ARouteHandler(Protocol[_StateContra]): - """Protocol for a Teams route handler that receives a :class:`A2ATurnContext`.""" + """Protocol for a A2A route handler that receives a :class:`A2ATurnContext`.""" def __call__( self, context: A2ATurnContext, state: _StateContra, / @@ -32,12 +32,12 @@ def __call__( def wrap_a2a_route_handler( handler: A2ARouteHandler[_StateContra], app: AgentApplication ) -> RouteHandler[_StateContra]: - """Adapt a :class:`TeamsRouteHandler` into a plain :class:`RouteHandler`. + """Adapt a :class:`A2ARouteHandler` into a plain :class:`RouteHandler`. Wraps *handler* so that the core routing engine (which passes a plain :class:`TurnContext`) receives a compatible callable. - :param handler: The Teams-specific handler to wrap. + :param handler: The A2A-specific handler to wrap. :param app: The agent application handling the turn. :return: A :class:`RouteHandler` that upgrades the context before delegating. """ From eac83b8508209a583f84348723528146e310fa7c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 22 Sep 2026 10:51:17 -0700 Subject: [PATCH 20/39] A2A metadata generation --- .../hosting/a2a/a2a_adapter.py | 2 +- .../hosting/a2a/activity/a2a_activity.py | 4 +- .../hosting/a2a/activity/utils.py | 66 ++++--- .../hosting/a2a/blob_task_store.py | 186 ++++++++++++++++++ 4 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index 33f05fc0f..89dce7a9b 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -97,7 +97,7 @@ def __init__( self._skills: list[AgentSkill] = skills or [] self._agent_interfaces: list[AgentInterface] = agent_interfaces or [ AgentInterface( - url="/a2a", + url="http://localhost:8000/a2a", protocol_binding=TransportProtocol.JSONRPC, ) ] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index a44fa50d9..2fbe64827 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -16,6 +16,7 @@ Part, TaskState, ) +from google.protobuf.json_format import MessageToDict from pydantic import Field @@ -122,6 +123,7 @@ def _create_activity( conversation=ConversationAccount(id=conversation_id), recipient=agent if is_ingress else user, from_property=user if is_ingress else agent, + attachments=[], ) for part in parts: @@ -145,7 +147,7 @@ def _create_activity( elif part.data: activity.attachments.append(Attachment( content_type="application/json", - content=json.dumps(part.data), + content=json.dumps(MessageToDict(part.data)), name="A2A DataPart", )) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 857a459b6..2b4a64b7a 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -1,9 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Mapping, cast, Sequence +from functools import lru_cache +from typing import Mapping, cast, Sequence, Any from uuid import uuid4 +from pydantic import BaseModel, TypeAdapter +from pydantic.errors import PydanticSchemaGenerationError + from a2a.types import ( Artifact, Message, @@ -11,9 +15,12 @@ Role, TaskState, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value from microsoft_agents.activity import ( Activity, + Entity, InputHints, StreamInfo, ) @@ -23,6 +30,10 @@ _SCHEMAS: dict[str, Mapping] = {} +def _to_protobuf_value(data: dict) -> Value: + return ParseDict(data, Value()) + + def activity_to_artifact( activity: Activity, artifact_id: str | None = None, include_entities: bool = True ) -> Artifact: @@ -42,10 +53,10 @@ def activity_to_artifact( artifact.parts.append(Part(text=activity.text)) if activity.value is not None and isinstance(activity.value, dict): - artifact.parts.append(Part(data=activity.value)) + artifact.parts.append(Part(data=_to_protobuf_value(activity.value))) - for attachment in activity.attachments: - if attachment.content_url and not isinstance(attachment.content, str): + for attachment in activity.attachments or []: + if not attachment.content_url and not attachment.content: continue part: Part @@ -57,7 +68,7 @@ def activity_to_artifact( ) elif isinstance(attachment.content, dict): part = Part( - data=attachment.content, + data=_to_protobuf_value(attachment.content), media_type=attachment.content_type, ) else: @@ -66,38 +77,47 @@ def activity_to_artifact( artifact.parts.append(part) if include_entities: - for entity in activity.entities: + for entity in activity.entities or []: if not isinstance(entity, StreamInfo): - - if entity.type not in _SCHEMAS: - _SCHEMAS[entity.type] = _to_a2a_metadata( - entity, _ENTITY_TYPE_TEMPLATE.format(entity.type) - ) - - cached_metadata = _SCHEMAS[entity.type] - artifact.parts.append( Part( - metadata=cached_metadata, - data=entity.model_dump(exclude_none=True), + metadata=_get_a2a_metadata(entity), + data=_to_protobuf_value( + entity.model_dump(exclude_none=True) + ), ) ) return artifact -def _to_a2a_metadata(entity: Entity, content_type: str) -> dict: +@lru_cache +def _try_get_json_schema(data_type: type) -> dict[str, Any] | None: + try: + if issubclass(data_type, BaseModel): + return data_type.model_json_schema() + return TypeAdapter(data_type).json_schema() + except PydanticSchemaGenerationError: + return None + +def _get_a2a_metadata(data: object) -> dict: """Convert the given data to A2A metadata. - :param data: The data to convert. + :param data: The data to convert to A2A metadata. :param content_type: The content type of the data. :return: A dictionary representing the A2A metadata. """ + if data is None: + raise ValueError("Data cannot be None.") - return { - "mimeType": content_type, + metadata: dict[str, Any] = { + "mimeType": "application/json", "type": "object", - # "schema": TODO? } + schema = _try_get_json_schema(type(data)) + if schema is not None: + metadata["schema"] = schema + + return metadata def create_artifact_from_data( data: dict, @@ -124,8 +144,8 @@ def create_artifact_from_data( description=description, parts=[ Part( - data=data, - metadata=_to_a2a_metadata(data, media_type or data.__class__.__name__), + data=_to_protobuf_value(data), + metadata=_get_a2a_metadata(data), ) ], ) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py new file mode 100644 index 000000000..cd8182dc2 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py @@ -0,0 +1,186 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import urllib.parse + +from asyncio import Lock +from typing import overload + +from a2a.server.context import ServerCallContext +from a2a.server.tasks import TaskStore +from a2a.types.a2a_pb2 import ( + ListTasksRequest, + ListTasksResponse, + Task +) + + +from azure.storage.blob.aio import ( + ContainerClient, + BlobServiceClient, +) +from microsoft_agents.hosting.core.storage.error_handling import ( + ignore_error, + is_status_code_error, +) + +_TASK_PREFIX = "TODO" + +class BlobTaskStore(TaskStore): + """A task store implementation backed by a blob storage.""" + + @overload + def __init__( + self, + container_client: ContainerClient, + ) -> None: ... + @overload + def __init__( + self, + *, + data_connection_string: str, + container_name: str, + ) -> None: ... + def __init__( + self, + container_client: ContainerClient | None = None, + *, + data_connection_string: str | None = None, + container_name: str | None = None, + ) -> None: + """Initializes the blob task store with either a container client or a connection string and container name. + + :param container_client: An optional ContainerClient instance for accessing the blob storage. + :param data_connection_string: An optional connection string for the blob storage. + :param container_name: An optional container name within the blob storage. + """ + + self._initialized = False + self._init_lock = Lock() + + if container_client is not None: + self._container_client = container_client + return + elif data_connection_string is not None and container_name is not None: + blob_service_client = BlobServiceClient.from_connection_string(data_connection_string) + self._container_client = blob_service_client.get_container_client(container_name) + return + else: + raise ValueError("Invalid combination of parameters provided.") + + async def _ensure_container_exists(self) -> None: + """Ensures that the blob container exists, creating it if necessary.""" + + if not self._initialized: + async with self._init_lock: + if not self._initialized: + await ignore_error( + self._container_client.create_container(), is_status_code_error(409) + ) + self._initialized = True + + @staticmethod + def _should_include_task(task: Task, request: ListTasksRequest) -> bool: + """Determines whether a task should be included in the list based on the request parameters.""" + + if request.status != task.status: + return False + if request.context_id and request.context_id != task.context_id: + return False + return True + + @staticmethod + def _get_blob_name(task_id: str) -> str: + """Generates a blob name for the given task ID.""" + + if not task_id: + raise ValueError("Task ID cannot be empty.") + return f"{_TASK_PREFIX}{urllib.parse.quote_plus(task_id)}" + + async def save(self, task: Task, context: ServerCallContext) -> None: + """Saves or updates a task in the blob store.""" + await self._ensure_container_exists() + + blob_name = self._get_blob_name(task.id) + blob_client = self._container_client.get_blob_client(blob_name) + serialized_task = task.SerializeToString() + + await blob_client.upload_blob( + data=serialized_task, + overwrite=True, + length=len(serialized_task), + ) + + async def get(self, task_id: str, context: ServerCallContext) -> Task | None: + """Retrieves a task from the blob store by its ID. + + :param task_id: The ID of the task to retrieve. + :param context: The server call context. + :return: The task if found, otherwise None. + """ + await self._ensure_container_exists() + + return await self._download_task(task_id) + + async def list(self, params: ListTasksRequest, context: ServerCallContext) -> ListTasksResponse: + """Retrives a list of tasks from the store. + + :param params: The parameters for listing tasks. + :param context: The server call context. + :return: A response containing the list of tasks. + """ + await self._ensure_container_exists() + + tasks: list[Task] = [] + + items = self._container_client.list_blobs( + name_starts_with=_TASK_PREFIX, + results_per_page=params.page_size + ) + + iterator = items.by_page(params.page_token) + first_page = await anext(iterator) + + if not first_page: + return ListTasksResponse(tasks=[], next_page_token=None) + + next_page_token = getattr(first_page, "continuation_token", None) + + # TODO -> + async for blob in first_page: + task = await self._download_task_blob(blob.name) + if task and self._should_include_task(task, params): + tasks.append(task) + + return ListTasksResponse(tasks=tasks, next_page_token=next_page_token) + + + + + async def delete(self, task_id: str, context: ServerCallContext) -> None: + """Deletes a task from the blob store by its ID. + + :param task_id: The ID of the task to delete. + :param context: The server call context.s + """ + await self._ensure_container_exists() + + blob_name = self._get_blob_name(task_id) + await self._container_client.get_blob_client(blob_name).delete_blob() + + async def _download_task(self, task_id: str) -> Task | None: + return await self._download_task_blob(self._get_blob_name(task_id)) + + async def _download_task_blob(self, blob_name: str) -> Task | None: + item = await ignore_error( + self._container_client.download_blob(blob=blob_name, timeout=5), + is_status_code_error(404), + ) + if not item: + return None + + serialized_task: bytes = await item.readall() + task = Task() + task.ParseFromString(serialized_task) + + return task \ No newline at end of file From 95670d01ebcbba706b32ef83c74bb195b0487395 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 22 Sep 2026 11:33:50 -0700 Subject: [PATCH 21/39] Removing AgentRequestContext --- .../microsoft_agents/activity/activity.py | 2 +- .../hosting/a2a/a2a_adapter.py | 51 ++++++++++++++----- .../hosting/a2a/activity/a2a_activity.py | 1 - .../request_handling/a2a_agent_executor.py | 19 ++++++- .../a2a/request_handling/a2a_http_adapter.py | 2 +- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 02437811b..a75c27e9c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -200,7 +200,7 @@ class Activity(AgentsModel): text_highlights: list[TextHighlight] = None semantic_action: SemanticAction = None caller_id: NonEmptyString = None - request_id: str | None = Field(None, exclude=True) + request_id: Annotated[str | None, Field(None, exclude=True)] = None @field_validator("entities", mode="before") @classmethod diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index 89dce7a9b..fadbb1cfd 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -30,9 +30,12 @@ ActivityTypes, CallerIdConstants, Channels, + ChannelAccount, + ChannelId, EndOfConversationCodes, InvokeResponse, ResourceResponse, + RoleTypes, StreamInfo, ) from microsoft_agents.hosting.core import ( @@ -57,6 +60,7 @@ ) from .activity import utils, A2AActivity +from .activity.a2a_activity import _DEFAULT_USER_ID from .server._constants import _CLAIMS_IDENTITY_KEY @@ -107,7 +111,6 @@ def __init__( task_store=self._task_store, agent_card=self._get_basic_agent_card(), ) - self._context_map: dict[str, AgentRequestContext] = {} @property def skills(self) -> list[AgentSkill]: @@ -153,21 +156,41 @@ async def execute_agent_turn( ) activity.request_id = request_id - self._context_map[request_id] = AgentRequestContext( - request_id=request_id, - identity=identity, - event_queue=event_queue, + await self._process_activity_with_a2a( + identity, + activity, + context, + event_queue, + ) + + async def cancel_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + """Cancel an ongoing agent turn given the request context and event queue. + + :param context: The request context for the agent turn. + :param event_queue: The event queue for the agent turn. + """ + + end_of_conv_activity = Activity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.user_cancelled, + channel_id=ChannelId(Channels.a2a), + recipient=ChannelAccount(id="assistant", role=RoleTypes.agent), + from_property=ChannelAccount(id=_DEFAULT_USER_ID, role=RoleTypes.user), + ) + + identity = context.call_context.state.get( + _CLAIMS_IDENTITY_KEY, ClaimsIdentity() + ) + if not isinstance(identity, ClaimsIdentity): + raise RuntimeError("Invalid identity in context call state.") + + await self._process_activity_with_a2a( + identity, + end_of_conv_activity, + context, + event_queue, ) - try: - await self._process_activity_with_a2a( - identity, - activity, - context, - event_queue, - ) - finally: - del self._context_map[request_id] def _create_turn_context( self, diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index 2fbe64827..7b63df1dd 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -35,7 +35,6 @@ from . import utils _DEFAULT_USER_ID = "unknown" -# _ENTITY_TYPE_TEMPLATE = "application/vnd.microsoft.entity.{0}" class A2AActivity(Activity): """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py index 75d94d0b1..24d164361 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py @@ -4,6 +4,7 @@ import logging from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.tasks import TaskUpdater from a2a.server.events import EventQueue from .a2a_http_adapter import A2AHttpAdapter @@ -31,6 +32,15 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non logger.warning("No message found in the request context. Dropping request.") return + # If there is no current task, this is not a continuation + if context.current_task is None: + task_updater = TaskUpdater( + event_queue=event_queue, + task_id=context.task_id or '', + context_id=context.context_id or '', + ) + await task_updater.submit() + await self._adapter.execute_agent_turn( context, event_queue, @@ -46,4 +56,11 @@ async def cancel( :param context: The request context containing the message and other metadata. :param event_queue: The event queue for handling events during the turn. """ - pass + task_id = context.task_id + + updater = TaskUpdater( + event_queue=event_queue, + task_id=task_id or '', + context_id=context.context_id or '', + ) + await updater.cancel() \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py index 36ee41f0e..1fd85b3eb 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py @@ -40,7 +40,7 @@ async def execute_agent_turn(self, context: RequestContext, event_queue: EventQu :param event_queue: The event queue for the agent turn. """ ... - + async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) -> AgentCard: """Process a request for the agent card. From b83df378ada5f7a799c40026c9c4452a218024e1 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 22 Sep 2026 13:21:19 -0700 Subject: [PATCH 22/39] Improved testing and minor tweaks to Task handling --- .../activity/conversation_reference.py | 2 +- .../microsoft_agents/hosting/a2a/__init__.py | 3 +- .../hosting/a2a/a2a_adapter.py | 14 +- .../hosting/a2a/request_handling/__init__.py | 2 - .../request_handling/a2a_agent_executor.py | 19 +- .../request_handling/agent_request_context.py | 15 - .../hosting/fastapi/cloud_adapter.py | 81 ---- tests/hosting_a2a/__init__.py | 0 tests/hosting_a2a/activity/__init__.py | 0 .../hosting_a2a/activity/test_a2a_activity.py | 117 +++++ tests/hosting_a2a/activity/test_utils.py | 190 ++++++++ .../hosting_a2a/request_handling/__init__.py | 0 .../request_handling/test_request_handling.py | 132 ++++++ tests/hosting_a2a/server/__init__.py | 0 tests/hosting_a2a/server/test_routes.py | 110 +++++ .../test_sdk_server_call_context_builder.py | 65 +++ tests/hosting_a2a/test_a2a_adapter.py | 429 ++++++++++++++++++ tests/hosting_a2a/test_a2a_agent_extension.py | 36 ++ tests/hosting_a2a/test_a2a_client.py | 53 +++ tests/hosting_a2a/test_a2a_turn_context.py | 80 ++++ tests/hosting_a2a/test_add_a2a.py | 92 ++++ tests/hosting_a2a/test_blob_task_store.py | 98 ++++ 22 files changed, 1428 insertions(+), 110 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/agent_request_context.py create mode 100644 tests/hosting_a2a/__init__.py create mode 100644 tests/hosting_a2a/activity/__init__.py create mode 100644 tests/hosting_a2a/activity/test_a2a_activity.py create mode 100644 tests/hosting_a2a/activity/test_utils.py create mode 100644 tests/hosting_a2a/request_handling/__init__.py create mode 100644 tests/hosting_a2a/request_handling/test_request_handling.py create mode 100644 tests/hosting_a2a/server/__init__.py create mode 100644 tests/hosting_a2a/server/test_routes.py create mode 100644 tests/hosting_a2a/server/test_sdk_server_call_context_builder.py create mode 100644 tests/hosting_a2a/test_a2a_adapter.py create mode 100644 tests/hosting_a2a/test_a2a_agent_extension.py create mode 100644 tests/hosting_a2a/test_a2a_client.py create mode 100644 tests/hosting_a2a/test_a2a_turn_context.py create mode 100644 tests/hosting_a2a/test_add_a2a.py create mode 100644 tests/hosting_a2a/test_blob_task_store.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py index dc69d2b6a..131736926 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_reference.py @@ -52,7 +52,7 @@ class ConversationReference(AgentsModel): conversation: ConversationAccount channel_id: Optional[ChannelId] = None locale: Optional[NonEmptyString] = None - service_url: NonEmptyString = None + service_url: str | None = None request_id: str | None = None def get_continuation_activity(self) -> Activity: diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py index f3f83bc50..8baf40704 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from .activity import A2AActivity -from .request_handling import A2AHttpAdapter, AgentRequestContext +from .request_handling import A2AHttpAdapter from .server import ( create_jsonrpc_routes, create_rest_routes, @@ -18,7 +18,6 @@ __all__ = [ "A2AActivity", "A2AHttpAdapter", - "AgentRequestContext", "create_jsonrpc_routes", "create_rest_routes", "SDKServerCallContextBuilder", diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index fadbb1cfd..f3cf305cd 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -42,9 +42,10 @@ Agent, AuthenticationConstants, ChannelAdapter, + ChannelServiceAdapter, ClaimsIdentity, TurnContext, - ChannelServiceAdapter, + MiddlewareSet, ) from microsoft_agents.hosting.core.channel_adapter_protocol import ( ChannelAdapterProtocol, @@ -56,7 +57,6 @@ from .request_handling import ( A2AHttpAdapter, A2ARequestHandler, - AgentRequestContext, ) from .activity import utils, A2AActivity @@ -91,6 +91,7 @@ def __init__( :param skills: The list of skills associated with the adapter. :param task_store: Optional task store for managing tasks. If not provided, an in-memory task store will be used. """ + self.middleware_set = MiddlewareSet() self._agent = agent self._agent_card_name = agent_card_name @@ -433,12 +434,13 @@ async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) - agent_card = self._get_basic_agent_card() url_parts = urlsplit(request.url) + request_origin = f"{url_parts.scheme}://{url_parts.netloc}" if not self._agent_interfaces: agent_card.supported_interfaces.append( AgentInterface( protocol_binding=TransportProtocol.JSONRPC, - url=f"{url_parts.scheme}://{url_parts.hostname}{path_prefix}/", + url=f"{request_origin}{path_prefix}/", protocol_version="1.0", ) ) @@ -448,9 +450,13 @@ async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) - TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON, ): + interface_url = agent_interface.url + if interface_url.startswith("/"): + interface_url = f"{request_origin}{interface_url}" + agent_card.supported_interfaces.append(AgentInterface( protocol_binding=agent_interface.protocol_binding, - url=agent_interface.url, + url=interface_url, protocol_version="1.0", )) else: diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py index 2f915496a..b1364e8e4 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py @@ -4,11 +4,9 @@ from .a2a_agent_executor import A2AAgentExecutor from .a2a_http_adapter import A2AHttpAdapter from .a2a_request_handler import A2ARequestHandler -from .agent_request_context import AgentRequestContext __all__ = [ "A2AAgentExecutor", "A2AHttpAdapter", "A2ARequestHandler", - "AgentRequestContext", ] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py index 24d164361..c15b0c1a1 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py @@ -6,6 +6,8 @@ from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.tasks import TaskUpdater from a2a.server.events import EventQueue +from a2a.types import Task, TaskState, TaskStatus +from google.protobuf.timestamp_pb2 import Timestamp from .a2a_http_adapter import A2AHttpAdapter @@ -34,12 +36,19 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non # If there is no current task, this is not a continuation if context.current_task is None: - task_updater = TaskUpdater( - event_queue=event_queue, - task_id=context.task_id or '', - context_id=context.context_id or '', + timestamp = Timestamp() + timestamp.GetCurrentTime() + await event_queue.enqueue_event( + Task( + id=context.task_id or "", + context_id=context.context_id or "", + status=TaskStatus( + state=TaskState.TASK_STATE_SUBMITTED, + timestamp=timestamp, + ), + history=[context.message], + ) ) - await task_updater.submit() await self._adapter.execute_agent_turn( context, diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/agent_request_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/agent_request_context.py deleted file mode 100644 index befdd4f06..000000000 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/agent_request_context.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from dataclasses import dataclass - -from a2a.server.events import EventQueue - -from microsoft_agents.hosting.core import ClaimsIdentity - - -@dataclass -class AgentRequestContext: - request_id: str - identity: ClaimsIdentity - event_queue: EventQueue diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py index 961e76411..c56f913e0 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -65,87 +65,6 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: # Convert HttpResponse to FastAPI Response return self._to_fastapi_response(http_response) - async def process_request( - self, request: HttpRequestProtocol, agent: Agent - ) -> HttpResponse: - """Process an incoming HTTP request. - - Args: - request: The HTTP request to process. - agent: The agent to handle the request. - - Returns: - HttpResponse with the result. - - Raises: - TypeError: If request or agent is None. - """ - if not request: - raise TypeError("HttpAdapterBase.process_request: request can't be None") - if not agent: - raise TypeError("HttpAdapterBase.process_request: agent can't be None") - - with spans.AdapterProcess() as span: - - if request.method != "POST": - return HttpResponseFactory.method_not_allowed() - - try: - body = await request.json() - except Exception: - return HttpResponseFactory.bad_request( - "Invalid JSON or unsupported Content-Type" - ) - - activity: Activity = Activity.model_validate(body) - span.share(activity=activity) - - # Get claims identity (default to anonymous if not set by middleware) - claims_identity: ClaimsIdentity = ( - request.get_claims_identity() or ClaimsIdentity() - ) - - # Validate required activity fields - if ( - not activity.type - or not activity.conversation - or not activity.conversation.id - ): - return HttpResponseFactory.bad_request( - "Activity must have type and conversation.id" - ) - - if not self._validate_service_url(claims_identity, activity): - return HttpResponseFactory.unauthorized( - "Service URL is not allowed by the host validator." - ) - - try: - # Process the inbound activity with the agent - invoke_response = await self.process_activity( - claims_identity, activity, agent.on_turn - ) - - # Check if we need to return a synchronous response - if ( - activity.type == "invoke" - or activity.delivery_mode == DeliveryModes.expect_replies - ): - with spans.AdapterWriteResponse(activity): - # Invoke and ExpectReplies cannot be performed async - invoke_response_status = ( - invoke_response.status if invoke_response else None - ) - return HttpResponseFactory.json( - invoke_response.body if invoke_response else None, - invoke_response_status or HTTPStatus.NOT_IMPLEMENTED, - ) - - return HttpResponseFactory.accepted() - - except PermissionError: - return HttpResponseFactory.unauthorized() - @staticmethod def _to_fastapi_response(http_response: HttpResponse) -> Response: """Convert HttpResponse to FastAPI Response.""" diff --git a/tests/hosting_a2a/__init__.py b/tests/hosting_a2a/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/hosting_a2a/activity/__init__.py b/tests/hosting_a2a/activity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/hosting_a2a/activity/test_a2a_activity.py b/tests/hosting_a2a/activity/test_a2a_activity.py new file mode 100644 index 000000000..356aed215 --- /dev/null +++ b/tests/hosting_a2a/activity/test_a2a_activity.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from a2a.types import Message, Part, Role, TaskState +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value + +from microsoft_agents.activity import ( + ActivityTypes, + Channels, + DeliveryModes, + RoleTypes, +) +from microsoft_agents.hosting.a2a.activity import A2AActivity + + +def _message(*parts: Part, task_id: str = "task-1") -> Message: + return Message( + message_id="message-1", + context_id="context-1", + task_id=task_id, + role=Role.ROLE_USER, + parts=list(parts), + ) + + +def test_from_message_requires_task_id(): + message = _message(Part(text="hello"), task_id="") + + with pytest.raises(ValueError, match="Task ID is required"): + A2AActivity.from_message("request-1", None, message) + + +def test_from_message_maps_message_properties(): + message = _message(Part(text="hello")) + + activity = A2AActivity.from_message("request-1", None, message) + + assert activity.type == ActivityTypes.message + assert activity.channel_id == Channels.a2a + assert activity.request_id == "request-1" + assert activity.text == "hello" + assert activity.conversation.id == "task-1" + assert activity.delivery_mode == DeliveryModes.stream + assert activity.from_property.role == RoleTypes.user + assert activity.recipient.role == RoleTypes.agent + assert activity.channel_data is message + + +def test_from_message_uses_explicit_task_id_and_populates_missing_context_id(): + message = _message(Part(text="hello"), task_id="") + message.context_id = "" + + activity = A2AActivity.from_message("request-1", "explicit-task", message) + + assert activity.conversation.id == "explicit-task" + assert message.task_id == "explicit-task" + assert message.context_id + + +def test_create_activity_maps_all_supported_part_types(): + activity = A2AActivity._create_activity( + "conversation-1", + [ + Part(text="hello "), + Part(text="world"), + Part( + url="https://example.com/image.png", + media_type="image/png", + filename="image.png", + ), + Part( + raw=b"file contents", + media_type="application/octet-stream", + filename="data.bin", + ), + Part(data=ParseDict({"answer": 42}, Value())), + ], + is_ingress=True, + is_streaming=False, + ) + + assert activity.text == "hello world" + assert activity.delivery_mode == DeliveryModes.expect_replies + assert len(activity.attachments) == 3 + + url_attachment, raw_attachment, data_attachment = activity.attachments + assert url_attachment.content_url == "https://example.com/image.png" + assert url_attachment.content_type == "image/png" + assert url_attachment.name == "image.png" + assert raw_attachment.content == b"file contents" + assert raw_attachment.content_type == "application/octet-stream" + assert raw_attachment.name == "data.bin" + assert data_attachment.content == '{"answer": 42.0}' + assert data_attachment.content_type == "application/json" + assert data_attachment.name == "A2A DataPart" + + +def test_to_message_and_artifact_delegate_activity_content(): + activity = A2AActivity(type=ActivityTypes.message, text="result") + + message = activity.to_message("context-1", "task-1") + artifact = activity.to_artifact("artifact-1") + + assert message.context_id == "context-1" + assert message.task_id == "task-1" + assert message.role == Role.ROLE_AGENT + assert [part.text for part in message.parts] == ["result"] + assert artifact.artifact_id == "artifact-1" + assert [part.text for part in artifact.parts] == ["result"] + + +def test_get_task_state_delegates_to_activity_state(): + activity = A2AActivity(type=ActivityTypes.message) + + assert activity.get_task_state() == TaskState.TASK_STATE_WORKING diff --git a/tests/hosting_a2a/activity/test_utils.py b/tests/hosting_a2a/activity/test_utils.py new file mode 100644 index 000000000..f04221961 --- /dev/null +++ b/tests/hosting_a2a/activity/test_utils.py @@ -0,0 +1,190 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from types import SimpleNamespace + +import pytest +from a2a.types import Message, Role, TaskState +from google.protobuf.json_format import MessageToDict + +from microsoft_agents.activity import ( + Activity, + Attachment, + Entity, + InputHints, + StreamInfo, +) +from microsoft_agents.hosting.a2a.activity import utils + + +def test_activity_to_artifact_maps_text_value_and_attachments(): + activity = Activity( + type="message", + text="hello", + value={"result": "ok"}, + attachments=[ + Attachment( + content_type="image/png", + content_url="https://example.com/image.png", + name="image.png", + ), + Attachment( + content_type="application/json", + content={"count": 2}, + name="result.json", + ), + ], + ) + + artifact = utils.activity_to_artifact(activity, artifact_id="artifact-1") + + assert artifact.artifact_id == "artifact-1" + assert artifact.parts[0].text == "hello" + assert MessageToDict(artifact.parts[1].data)["result"] == "ok" + assert artifact.parts[2].url == "https://example.com/image.png" + assert artifact.parts[2].media_type == "image/png" + assert artifact.parts[2].filename == "image.png" + assert MessageToDict(artifact.parts[3].data)["count"] == 2 + assert artifact.parts[3].media_type == "application/json" + + +def test_activity_to_artifact_adds_entities_and_excludes_stream_info(): + activity = Activity( + type="message", + entities=[ + Entity(type="citation"), + StreamInfo(stream_id="stream-1", stream_sequence=1), + ], + ) + + artifact = utils.activity_to_artifact(activity) + + assert len(artifact.parts) == 1 + assert MessageToDict(artifact.parts[0].data)["type"] == "citation" + assert artifact.parts[0].metadata["mimeType"] == "application/json" + assert artifact.parts[0].metadata["type"] == "object" + assert artifact.parts[0].metadata["schema"]["title"] == "Entity" + assert artifact.parts[0].metadata["schema"]["required"] == ["type"] + + +def test_activity_to_artifact_can_exclude_entities(): + activity = Activity(type="message", entities=[Entity(type="citation")]) + + artifact = utils.activity_to_artifact(activity, include_entities=False) + + assert list(artifact.parts) == [] + + +def test_activity_to_artifact_rejects_unsupported_attachment_content(): + activity = Activity( + type="message", + attachments=[ + Attachment( + content_type="text/plain", + content="plain text", + ) + ], + ) + + with pytest.raises(RuntimeError, match="Unsupported attachment content type"): + utils.activity_to_artifact(activity) + + +def test_create_artifact_from_data_returns_none_for_none(): + assert utils.create_artifact_from_data(None) is None + + +def test_create_artifact_from_data_populates_artifact(): + artifact = utils.create_artifact_from_data( + {"answer": 42}, + name="result", + description="The result", + media_type="application/json", + artifact_id="artifact-1", + ) + + assert artifact.artifact_id == "artifact-1" + assert artifact.name == "result" + assert artifact.description == "The result" + assert MessageToDict(artifact.parts[0].data)["answer"] == 42 + assert artifact.parts[0].metadata["mimeType"] == "application/json" + assert artifact.parts[0].metadata["type"] == "object" + assert artifact.parts[0].metadata["schema"] == { + "additionalProperties": True, + "type": "object", + } + + +def test_get_a2a_metadata_rejects_none(): + with pytest.raises(ValueError, match="Data cannot be None"): + utils._get_a2a_metadata(None) + + +def test_get_a2a_metadata_reuses_cached_schema_for_same_type(): + utils._try_get_json_schema.cache_clear() + + first = utils._get_a2a_metadata(Entity(type="citation")) + second = utils._get_a2a_metadata(Entity(type="mention")) + + assert first["schema"] == second["schema"] + assert utils._try_get_json_schema.cache_info().misses == 1 + assert utils._try_get_json_schema.cache_info().hits == 1 + + +def test_create_message_without_activity_has_agent_role_and_no_parts(): + message = utils.create_message("context-1", "task-1", None) + + assert message.context_id == "context-1" + assert message.task_id == "task-1" + assert message.message_id + assert message.role == Role.ROLE_AGENT + assert list(message.parts) == [] + + +@pytest.mark.parametrize( + ("activity", "expected"), + [ + (Activity(type="message"), False), + (Activity(type="message", text="hello"), True), + ( + Activity( + type="message", + attachments=[Attachment(content_type="text/plain", content="hello")], + ), + True, + ), + ], +) +def test_has_message_content(activity, expected): + assert utils.has_message_content(activity) is expected + + +def test_get_task_state_maps_input_hint(): + assert ( + utils.get_task_state( + Activity(type="message", input_hint=InputHints.expecting_input) + ) + == TaskState.TASK_STATE_INPUT_REQUIRED + ) + assert ( + utils.get_task_state(Activity(type="message")) + == TaskState.TASK_STATE_WORKING + ) + + +def test_get_incoming_message_returns_a2a_message(): + message = Message(message_id="message-1", role=Role.ROLE_USER) + context = SimpleNamespace( + activity=Activity(type="message", channel_data=message) + ) + + assert utils.get_incoming_message(context) is message + + +def test_get_incoming_message_rejects_other_channel_data(): + context = SimpleNamespace( + activity=Activity(type="message", channel_data={"message": "hello"}) + ) + + with pytest.raises(TypeError, match="Expected channel_data"): + utils.get_incoming_message(context) diff --git a/tests/hosting_a2a/request_handling/__init__.py b/tests/hosting_a2a/request_handling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/hosting_a2a/request_handling/test_request_handling.py b/tests/hosting_a2a/request_handling/test_request_handling.py new file mode 100644 index 000000000..3f1b7e641 --- /dev/null +++ b/tests/hosting_a2a/request_handling/test_request_handling.py @@ -0,0 +1,132 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from a2a.server.events import EventQueue +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentInterface, + Message, + Role, + Task, + TaskState, +) + +from microsoft_agents.hosting.a2a.request_handling import ( + A2AAgentExecutor, + A2ARequestHandler, +) + + +def _agent_card(name: str = "Test agent") -> AgentCard: + return AgentCard( + name=name, + description="Test agent description", + version="1.0.0", + supported_interfaces=[ + AgentInterface( + url="https://example.com/a2a", + protocol_binding="JSONRPC", + ) + ], + capabilities=AgentCapabilities(), + ) + + +@pytest.mark.asyncio +async def test_executor_delegates_message_to_adapter(): + adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) + executor = A2AAgentExecutor(adapter) + context = SimpleNamespace( + message=Message(message_id="message-1", role=Role.ROLE_USER), + current_task=object(), + ) + event_queue = AsyncMock(spec=EventQueue) + + await executor.execute(context, event_queue) + + adapter.execute_agent_turn.assert_awaited_once_with(context, event_queue) + + +@pytest.mark.asyncio +async def test_executor_submits_new_task_before_delegating(): + adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) + executor = A2AAgentExecutor(adapter) + context = SimpleNamespace( + message=Message(message_id="message-1", role=Role.ROLE_USER), + current_task=None, + task_id="task-1", + context_id="context-1", + ) + event_queue = AsyncMock(spec=EventQueue) + + await executor.execute(context, event_queue) + + task = event_queue.enqueue_event.call_args.args[0] + assert isinstance(task, Task) + assert task.id == "task-1" + assert task.context_id == "context-1" + assert task.status.state == TaskState.TASK_STATE_SUBMITTED + assert task.status.HasField("timestamp") + assert list(task.history) == [context.message] + adapter.execute_agent_turn.assert_awaited_once_with(context, event_queue) + + +@pytest.mark.asyncio +async def test_executor_drops_context_without_message(caplog): + adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) + executor = A2AAgentExecutor(adapter) + context = SimpleNamespace(message=None) + event_queue = AsyncMock(spec=EventQueue) + + with caplog.at_level("WARNING"): + await executor.execute(context, event_queue) + + adapter.execute_agent_turn.assert_not_awaited() + assert "No message found in the request context" in caplog.text + + +@pytest.mark.asyncio +async def test_executor_cancel_updates_task_without_calling_adapter(): + adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) + executor = A2AAgentExecutor(adapter) + context = SimpleNamespace(task_id="task-1", context_id="context-1") + event_queue = AsyncMock(spec=EventQueue) + updater = AsyncMock() + + with patch( + "microsoft_agents.hosting.a2a.request_handling." + "a2a_agent_executor.TaskUpdater", + return_value=updater, + ) as task_updater: + await executor.cancel(context, event_queue) + + task_updater.assert_called_once_with( + event_queue=event_queue, + task_id="task-1", + context_id="context-1", + ) + updater.cancel.assert_awaited_once() + adapter.execute_agent_turn.assert_not_awaited() + + +def test_request_handler_uses_adapter_and_updates_agent_card(): + adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) + original_card = _agent_card() + updated_card = _agent_card("Updated agent") + + handler = A2ARequestHandler( + adapter, + InMemoryTaskStore(), + original_card, + ) + handler.update_agent_card(updated_card) + + assert handler._adapter is adapter + assert handler.agent_executor._adapter is adapter + assert handler._agent_card is updated_card diff --git a/tests/hosting_a2a/server/__init__.py b/tests/hosting_a2a/server/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/hosting_a2a/server/test_routes.py b/tests/hosting_a2a/server/test_routes.py new file mode 100644 index 000000000..e5613b2dc --- /dev/null +++ b/tests/hosting_a2a/server/test_routes.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from starlette.requests import Request +from starlette.routing import Mount, Route + +from microsoft_agents.hosting.fastapi import JwtAuthorizationMiddleware +from microsoft_agents.hosting.a2a.server import routes +from microsoft_agents.hosting.a2a.server.sdk_server_call_context_builder import ( + SDKServerCallContextBuilder, +) + + +def test_create_jsonrpc_routes_supplies_sdk_context_builder(): + request_handler = MagicMock() + expected_routes = [MagicMock(spec=Route)] + + with patch.object( + routes, + "_create_jsonrpc_routes", + return_value=expected_routes, + ) as create_routes: + result = routes.create_jsonrpc_routes( + request_handler, + "/a2a", + enable_v0_3_compat=True, + ) + + assert result is expected_routes + create_routes.assert_called_once() + assert create_routes.call_args.args == (request_handler, "/a2a") + assert isinstance( + create_routes.call_args.kwargs["context_builder"], + SDKServerCallContextBuilder, + ) + assert create_routes.call_args.kwargs["enable_v0_3_compat"] is True + + +def test_create_rest_routes_supplies_sdk_context_builder(): + request_handler = MagicMock() + expected_routes = [MagicMock(spec=Route)] + + with patch.object( + routes, + "_create_rest_routes", + return_value=expected_routes, + ) as create_routes: + result = routes.create_rest_routes( + request_handler, + path_prefix="/a2a", + enable_v0_3_compat=True, + ) + + assert result is expected_routes + create_routes.assert_called_once() + assert create_routes.call_args.args == (request_handler,) + assert isinstance( + create_routes.call_args.kwargs["context_builder"], + SDKServerCallContextBuilder, + ) + assert create_routes.call_args.kwargs["path_prefix"] == "/a2a" + assert create_routes.call_args.kwargs["enable_v0_3_compat"] is True + + +@pytest.mark.asyncio +async def test_create_agent_card_routes_adapts_request_and_uses_interface_prefix(): + get_agent_card = AsyncMock(return_value=MagicMock()) + route = routes.create_agent_card_routes( + get_agent_card, + "/a2a/.well-known/agent-card.json", + )[0] + request = Request( + { + "type": "http", + "method": "GET", + "path": "/a2a/.well-known/agent-card.json", + "raw_path": b"/a2a/.well-known/agent-card.json", + "query_string": b"", + "headers": [], + "scheme": "https", + "server": ("example.com", 443), + "client": ("127.0.0.1", 1234), + } + ) + + with patch.object(routes, "agent_card_to_dict", return_value={"name": "agent"}): + response = await route.endpoint(request) + + assert route.path == "/a2a/.well-known/agent-card.json" + assert response.status_code == 200 + assert response.body == b'{"name":"agent"}' + get_agent_card.assert_awaited_once() + assert get_agent_card.call_args.args[1] == "/a2a" + + +def test_use_jwt_middleware_wraps_shared_mounted_route_once(): + async def endpoint(request): + return None + + route = Route("/messages", endpoint=endpoint) + original_app = route.app + mounted = Mount("/tenant", routes=[route]) + + routes.use_jwt_middleware([route, mounted]) + + assert isinstance(route.app, JwtAuthorizationMiddleware) + assert route.app.app is original_app diff --git a/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py b/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py new file mode 100644 index 000000000..21302bae8 --- /dev/null +++ b/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import MagicMock, patch + +from a2a.auth.user import UnauthenticatedUser +from starlette.requests import Request + +from microsoft_agents.hosting.core import ClaimsIdentity +from microsoft_agents.hosting.a2a.server._constants import _CLAIMS_IDENTITY_KEY +from microsoft_agents.hosting.a2a.server.sdk_server_call_context_builder import ( + SDKServerCallContextBuilder, +) + + +def _request(*, claims_identity=None): + scope = { + "type": "http", + "method": "GET", + "path": "/a2a", + "raw_path": b"/a2a", + "query_string": b"", + "headers": [ + (b"x-a2a-extensions", b"extension-1"), + (b"x-test", b"value"), + ], + "scheme": "https", + "server": ("example.com", 443), + "client": ("127.0.0.1", 1234), + "state": {}, + } + if claims_identity is not None: + scope["state"]["claims_identity"] = claims_identity + return Request(scope) + + +def test_build_copies_headers_extensions_and_claims_identity(): + identity = ClaimsIdentity({"sub": "agent-1"}, True) + request = _request(claims_identity=identity) + builder = SDKServerCallContextBuilder() + user = UnauthenticatedUser() + builder.build_user = MagicMock(return_value=user) + + with patch( + "microsoft_agents.hosting.a2a.server." + "sdk_server_call_context_builder.get_requested_extensions", + return_value={"extension-1"}, + ) as get_extensions: + context = builder.build(request) + + assert context.user is user + assert context.state["headers"]["x-test"] == "value" + assert context.state[_CLAIMS_IDENTITY_KEY] is identity + assert context.requested_extensions == {"extension-1"} + get_extensions.assert_called_once() + + +def test_build_omits_claims_identity_when_middleware_did_not_set_it(): + request = _request() + builder = SDKServerCallContextBuilder() + builder.build_user = MagicMock(return_value=UnauthenticatedUser()) + + context = builder.build(request) + + assert _CLAIMS_IDENTITY_KEY not in context.state diff --git a/tests/hosting_a2a/test_a2a_adapter.py b/tests/hosting_a2a/test_a2a_adapter.py new file mode 100644 index 000000000..55cede688 --- /dev/null +++ b/tests/hosting_a2a/test_a2a_adapter.py @@ -0,0 +1,429 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from a2a.server.agent_execution import RequestContext +from a2a.server.context import ServerCallContext +from a2a.server.events import EventQueue +from a2a.server.tasks import InMemoryTaskStore, TaskStore +from a2a.types import ( + AgentInterface, + AgentSkill, + Message, + Part, + Role, + SendMessageRequest, + TaskArtifactUpdateEvent, + TaskState, + TaskStatusUpdateEvent, +) +from a2a.utils.constants import TransportProtocol + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + CallerIdConstants, + Channels, + EndOfConversationCodes, + InputHints, + StreamInfo, +) +from microsoft_agents.hosting.core import ( + AuthenticationConstants, + ChannelServiceAdapter, + ClaimsIdentity, + TurnContext, +) +from microsoft_agents.hosting.a2a import A2AAdapter +from microsoft_agents.hosting.a2a.activity import A2AActivity +from microsoft_agents.hosting.a2a.server._constants import _CLAIMS_IDENTITY_KEY + + +def _agent(): + return SimpleNamespace(on_turn=AsyncMock()) + + +def _message() -> Message: + return Message( + message_id="message-1", + task_id="task-1", + context_id="context-1", + role=Role.ROLE_USER, + parts=[Part(text="hello")], + ) + + +def _request_context(identity=None, *, include_message=True): + call_context = ServerCallContext( + state={} if identity is None else {_CLAIMS_IDENTITY_KEY: identity} + ) + request = ( + SendMessageRequest(message=_message()) + if include_message + else None + ) + return RequestContext( + call_context, + request=request, + task_id="task-1", + context_id="context-1", + ) + + +def _turn_context(adapter, event_queue): + context = TurnContext( + adapter, + A2AActivity( + type=ActivityTypes.message, + channel_data=_message(), + ), + ClaimsIdentity(), + ) + context.services.set(EventQueue, event_queue) + return context + + +def test_constructor_uses_defaults_and_creates_request_handler(): + adapter = A2AAdapter(_agent()) + + assert isinstance(adapter._task_store, InMemoryTaskStore) + assert len(adapter.agent_interfaces) == 1 + assert adapter.agent_interfaces[0].url == "http://localhost:8000/a2a" + assert ( + adapter.agent_interfaces[0].protocol_binding + == TransportProtocol.JSONRPC + ) + assert adapter.skills == [] + assert adapter.a2a_request_handler is not None + + +def test_constructor_preserves_custom_interfaces_skills_and_task_store(): + interface = AgentInterface( + url="https://example.com/a2a", + protocol_binding=TransportProtocol.HTTP_JSON, + ) + skill = AgentSkill( + id="weather", + name="Weather", + description="Gets weather", + tags=["weather"], + ) + task_store = InMemoryTaskStore() + + adapter = A2AAdapter( + _agent(), + agent_interfaces=[interface], + skills=[skill], + task_store=task_store, + ) + + assert adapter.agent_interfaces == [interface] + assert adapter.skills == [skill] + assert adapter._task_store is task_store + + +@pytest.mark.asyncio +async def test_execute_agent_turn_requires_message(): + adapter = A2AAdapter(_agent()) + + with pytest.raises(ValueError, match="Context message is required"): + await adapter.execute_agent_turn( + _request_context(include_message=False), + MagicMock(spec=EventQueue), + ) + + +@pytest.mark.asyncio +async def test_execute_agent_turn_rejects_invalid_identity(): + adapter = A2AAdapter(_agent()) + context = _request_context(identity="invalid") + + with pytest.raises(RuntimeError, match="Invalid identity"): + await adapter.execute_agent_turn(context, MagicMock(spec=EventQueue)) + + +@pytest.mark.asyncio +async def test_execute_agent_turn_converts_message_and_processes_activity(): + identity = ClaimsIdentity({"sub": "agent-1"}) + context = _request_context(identity) + event_queue = MagicMock(spec=EventQueue) + adapter = A2AAdapter(_agent()) + adapter._process_activity_with_a2a = AsyncMock() + + await adapter.execute_agent_turn(context, event_queue) + + adapter._process_activity_with_a2a.assert_awaited_once() + actual_identity, activity, actual_context, actual_queue = ( + adapter._process_activity_with_a2a.call_args.args + ) + assert actual_identity is identity + assert isinstance(activity, A2AActivity) + assert activity.text == "hello" + assert activity.request_id + assert actual_context is context + assert actual_queue is event_queue + + +@pytest.mark.asyncio +async def test_process_activity_registers_services_and_runs_agent_pipeline(): + agent = _agent() + adapter = A2AAdapter(agent) + adapter.run_pipeline = AsyncMock() + identity = ClaimsIdentity() + request_context = MagicMock(spec=RequestContext) + event_queue = MagicMock(spec=EventQueue) + activity = A2AActivity(type=ActivityTypes.message) + + await adapter._process_activity_with_a2a( + identity, + activity, + request_context, + event_queue, + ) + + adapter.run_pipeline.assert_awaited_once() + context, handler = adapter.run_pipeline.call_args.args + assert handler is agent.on_turn + assert context.identity is identity + assert ( + context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] + == AuthenticationConstants.AGENTS_SDK_SCOPE + ) + assert context.services.get(RequestContext) is request_context + assert context.services.get(EventQueue) is event_queue + assert context.services.get(TaskStore) is adapter._task_store + + +@pytest.mark.asyncio +async def test_process_activity_sets_agent_caller_and_audience(): + identity = ClaimsIdentity( + { + AuthenticationConstants.VERSION_CLAIM: "2.0", + AuthenticationConstants.AUDIENCE_CLAIM: "target-agent", + AuthenticationConstants.AUTHORIZED_PARTY: "calling-agent", + } + ) + adapter = A2AAdapter(_agent()) + adapter.run_pipeline = AsyncMock() + activity = A2AActivity(type=ActivityTypes.message) + + await adapter._process_activity_with_a2a( + identity, + activity, + MagicMock(spec=RequestContext), + MagicMock(spec=EventQueue), + ) + + context = adapter.run_pipeline.call_args.args[0] + assert ( + context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] + == "app://calling-agent" + ) + assert activity.caller_id == ( + f"{CallerIdConstants.agent_to_agent_prefix}calling-agent" + ) + + +@pytest.mark.asyncio +async def test_process_activity_rejects_non_a2a_channel(): + adapter = A2AAdapter(_agent()) + + with pytest.raises(ValueError, match="channel_id must be 'a2a'"): + await adapter._process_activity_with_a2a( + ClaimsIdentity(), + Activity(type=ActivityTypes.message, channel_id=Channels.webchat), + MagicMock(spec=RequestContext), + MagicMock(spec=EventQueue), + ) + + +@pytest.mark.asyncio +async def test_send_activities_dispatches_supported_activity_types(): + adapter = A2AAdapter(_agent()) + adapter._on_streaming_response = AsyncMock() + adapter._on_message_response = AsyncMock() + adapter._on_end_of_conversation_response = AsyncMock() + context = MagicMock(spec=TurnContext) + stream_info = StreamInfo( + stream_id="stream-1", + stream_type="informative", + stream_sequence=1, + ) + streaming = A2AActivity( + type=ActivityTypes.message, + entities=[stream_info], + ) + message = A2AActivity(type=ActivityTypes.message, text="hello") + completed = A2AActivity(type=ActivityTypes.end_of_conversation) + ignored = Activity( + type=ActivityTypes.message, + channel_id=Channels.webchat, + ) + + result = await adapter.send_activities( + context, + [streaming, message, completed, ignored], + ) + + assert result == [] + adapter._on_streaming_response.assert_awaited_once_with( + context, + streaming, + stream_info, + ) + adapter._on_message_response.assert_awaited_once_with(context, message) + adapter._on_end_of_conversation_response.assert_awaited_once_with( + context, + completed, + ) + + +@pytest.mark.asyncio +async def test_message_response_enqueues_status_update(): + event_queue = MagicMock(spec=EventQueue) + event_queue.enqueue_event = AsyncMock() + adapter = A2AAdapter(_agent()) + context = _turn_context(adapter, event_queue) + activity = A2AActivity( + type=ActivityTypes.message, + text="working", + input_hint=InputHints.expecting_input, + ) + + await adapter._on_message_response(context, activity) + + event = event_queue.enqueue_event.call_args.args[0] + assert isinstance(event, TaskStatusUpdateEvent) + assert event.task_id == "task-1" + assert event.context_id == "context-1" + assert event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + assert event.status.message.parts[0].text == "working" + + +@pytest.mark.asyncio +async def test_streaming_response_enqueues_artifact_update(): + event_queue = MagicMock(spec=EventQueue) + event_queue.enqueue_event = AsyncMock() + adapter = A2AAdapter(_agent()) + context = _turn_context(adapter, event_queue) + activity = A2AActivity(type=ActivityTypes.message, text="chunk") + stream_info = StreamInfo( + stream_id="artifact-1", + stream_type="content", + stream_sequence=1, + ) + + await adapter._on_streaming_response(context, activity, stream_info) + + event = event_queue.enqueue_event.call_args.args[0] + assert isinstance(event, TaskArtifactUpdateEvent) + assert event.task_id == "task-1" + assert event.context_id == "context-1" + assert event.artifact.artifact_id == "artifact-1" + assert event.artifact.parts[0].text == "chunk" + assert event.last_chunk is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("code", "expected_state"), + [ + (EndOfConversationCodes.completed_successfully, TaskState.TASK_STATE_COMPLETED), + (EndOfConversationCodes.error, TaskState.TASK_STATE_FAILED), + (EndOfConversationCodes.user_cancelled, TaskState.TASK_STATE_CANCELED), + ], +) +async def test_end_of_conversation_maps_terminal_state(code, expected_state): + event_queue = MagicMock(spec=EventQueue) + event_queue.enqueue_event = AsyncMock() + adapter = A2AAdapter(_agent()) + context = _turn_context(adapter, event_queue) + activity = A2AActivity( + type=ActivityTypes.end_of_conversation, + code=code, + ) + + await adapter._on_end_of_conversation_response(context, activity) + + event = event_queue.enqueue_event.call_args.args[0] + assert isinstance(event, TaskStatusUpdateEvent) + assert event.status.state == expected_state + + +@pytest.mark.asyncio +async def test_get_agent_card_projects_interfaces_and_skills(): + interface = AgentInterface( + url="https://example.com/a2a", + protocol_binding=TransportProtocol.HTTP_JSON, + ) + skill = AgentSkill( + id="weather", + name="Weather", + description="Gets weather", + tags=["weather"], + examples=["Weather in Seattle"], + input_modes=["text/plain"], + output_modes=["application/json"], + ) + adapter = A2AAdapter( + _agent(), + agent_card_name="Test agent", + agent_card_description="Description", + agent_card_version="1.2.3", + agent_interfaces=[interface], + skills=[skill], + ) + + card = await adapter.get_agent_card( + SimpleNamespace(url="https://host.example/a2a/card"), + "/a2a", + ) + + assert card.name == "Test agent" + assert card.description == "Description" + assert card.version == "1.2.3" + assert card.supported_interfaces[0].url == "https://example.com/a2a" + assert ( + card.supported_interfaces[0].protocol_binding + == TransportProtocol.HTTP_JSON + ) + assert card.supported_interfaces[0].protocol_version == "1.0" + assert card.skills[0].id == "weather" + assert card.skills[0].examples == ["Weather in Seattle"] + + +@pytest.mark.asyncio +async def test_get_agent_card_resolves_relative_interface_against_request_origin(): + adapter = A2AAdapter( + _agent(), + agent_interfaces=[ + AgentInterface( + url="/a2a", + protocol_binding=TransportProtocol.JSONRPC, + ) + ], + ) + + card = await adapter.get_agent_card( + SimpleNamespace(url="http://127.0.0.1:41241/a2a/.well-known/agent-card.json"), + "/a2a", + ) + + assert card.supported_interfaces[0].url == "http://127.0.0.1:41241/a2a" + + +@pytest.mark.asyncio +async def test_update_and_delete_activity_are_not_supported(): + adapter = A2AAdapter(_agent()) + + with pytest.raises(NotImplementedError): + await adapter.update_activity( + MagicMock(spec=TurnContext), + Activity(type=ActivityTypes.message), + ) + + with pytest.raises(NotImplementedError): + await adapter.delete_activity(MagicMock(spec=TurnContext), "activity-1") diff --git a/tests/hosting_a2a/test_a2a_agent_extension.py b/tests/hosting_a2a/test_a2a_agent_extension.py new file mode 100644 index 000000000..ce7b824c1 --- /dev/null +++ b/tests/hosting_a2a/test_a2a_agent_extension.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.hosting.core import AgentApplication +from microsoft_agents.hosting.a2a.a2a_agent_extension import A2AAgentExtension + + +def test_message_registers_wrapped_handler_with_core_application(): + app = MagicMock(spec=AgentApplication) + core_decorator = MagicMock(return_value="registered-handler") + app.message.return_value = core_decorator + extension = A2AAgentExtension(app) + handler = AsyncMock() + wrapped_handler = AsyncMock() + + with patch( + "microsoft_agents.hosting.a2a.a2a_agent_extension.wrap_a2a_route_handler", + return_value=wrapped_handler, + ) as wrap_handler: + result = extension.message( + ["hello", "help"], + auth_handlers=["auth"], + rank=10, + )(handler) + + app.message.assert_called_once_with( + ["hello", "help"], + auth_handlers=["auth"], + rank=10, + ) + wrap_handler.assert_called_once_with(handler, app) + core_decorator.assert_called_once_with(wrapped_handler) + assert result == "registered-handler" + diff --git a/tests/hosting_a2a/test_a2a_client.py b/tests/hosting_a2a/test_a2a_client.py new file mode 100644 index 000000000..f573c0616 --- /dev/null +++ b/tests/hosting_a2a/test_a2a_client.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from types import SimpleNamespace + +import pytest +from a2a.server.agent_execution import RequestContext +from a2a.server.context import ServerCallContext +from a2a.server.events import EventQueue, EventQueueLegacy +from a2a.server.tasks import InMemoryTaskStore, TaskStore + +from microsoft_agents.hosting.a2a.a2a_client import A2AClient + + +class _Services: + def __init__(self, values): + self._values = values + + def get(self, key): + return self._values.get(key) + + +def _services(): + event_queue = EventQueueLegacy() + request_context = RequestContext( + ServerCallContext(), + task_id="task-1", + context_id="context-1", + ) + task_store = InMemoryTaskStore() + return { + EventQueue: event_queue, + RequestContext: request_context, + TaskStore: task_store, + } + + +def test_client_exposes_services_from_turn_context(): + services = _services() + client = A2AClient(SimpleNamespace(services=_Services(services))) + + assert client.event_queue is services[EventQueue] + assert client.request_context is services[RequestContext] + assert client.task_store is services[TaskStore] + + +@pytest.mark.parametrize("missing_service", [EventQueue, RequestContext, TaskStore]) +def test_client_requires_all_services(missing_service): + services = _services() + del services[missing_service] + + with pytest.raises(ValueError, match="Missing required services"): + A2AClient(SimpleNamespace(services=_Services(services))) diff --git a/tests/hosting_a2a/test_a2a_turn_context.py b/tests/hosting_a2a/test_a2a_turn_context.py new file mode 100644 index 000000000..fbe8e616c --- /dev/null +++ b/tests/hosting_a2a/test_a2a_turn_context.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import MagicMock + +from a2a.server.agent_execution import RequestContext +from a2a.server.context import ServerCallContext +from a2a.server.events import EventQueue, EventQueueLegacy +from a2a.server.tasks import InMemoryTaskStore, TaskStore + +from microsoft_agents.activity import Activity, ResourceResponse +from microsoft_agents.hosting.core import ( + AgentApplication, + ChannelAdapter, + ClaimsIdentity, + TurnContext, +) +from microsoft_agents.hosting.a2a.activity import A2AActivity +from microsoft_agents.hosting.a2a.a2a_turn_context import A2ATurnContext + + +class _Adapter(ChannelAdapter): + async def send_activities(self, context, activities): + return [ResourceResponse()] * len(activities) + + async def update_activity(self, context, activity): + return ResourceResponse(id=activity.id) + + async def delete_activity(self, context, reference): + return None + + +def _original_context(): + original = TurnContext( + _Adapter(), + Activity(type="message", text="hello"), + ClaimsIdentity(), + ) + original.services.set(EventQueue, EventQueueLegacy()) + original.services.set( + RequestContext, + RequestContext( + ServerCallContext(), + task_id="task-1", + context_id="context-1", + ), + ) + original.services.set(TaskStore, InMemoryTaskStore()) + return original + + +def test_wraps_existing_context_and_exposes_a2a_client(): + original = _original_context() + context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) + + assert isinstance(context.activity, A2AActivity) + assert context.activity.text == "hello" + assert context.client.event_queue is original.services.get(EventQueue) + assert context.client.request_context is original.services.get(RequestContext) + assert context.client.task_store is original.services.get(TaskStore) + + +def test_responded_property_is_forwarded_to_original_context(): + original = _original_context() + context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) + + context.responded = True + + assert original.responded is True + assert context.responded is True + + +def test_streaming_response_is_forwarded_to_original_context(): + original = _original_context() + streaming_response = MagicMock() + original._streaming_response = streaming_response + + context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) + + assert context.streaming_response is streaming_response diff --git a/tests/hosting_a2a/test_add_a2a.py b/tests/hosting_a2a/test_add_a2a.py new file mode 100644 index 000000000..b78849032 --- /dev/null +++ b/tests/hosting_a2a/test_add_a2a.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +from a2a.types import AgentInterface +from a2a.utils.constants import TransportProtocol +from fastapi import FastAPI + +from microsoft_agents.hosting.a2a import add_a2a as exported_add_a2a +from microsoft_agents.hosting.a2a.add_a2a import add_a2a + +add_a2a_module = importlib.import_module("microsoft_agents.hosting.a2a.add_a2a") + + +def _adapter(*interfaces): + return SimpleNamespace( + agent_interfaces=list(interfaces), + a2a_request_handler=MagicMock(), + get_agent_card=MagicMock(), + ) + + +def test_add_a2a_registers_jsonrpc_and_http_interfaces_with_jwt(): + app = FastAPI() + agent = MagicMock() + adapter = _adapter( + AgentInterface( + url="/rpc", + protocol_binding=TransportProtocol.JSONRPC, + ), + AgentInterface( + url="/rest", + protocol_binding=TransportProtocol.HTTP_JSON, + ), + ) + jsonrpc_routes = [MagicMock()] + jsonrpc_card_routes = [MagicMock()] + rest_routes = [MagicMock()] + rest_card_routes = [MagicMock()] + + with ( + patch.object( + add_a2a_module, + "_create_jsonrpc_interface_routes", + return_value=(jsonrpc_routes, jsonrpc_card_routes), + ) as create_jsonrpc, + patch.object( + add_a2a_module, + "_create_http_interface_routes", + return_value=(rest_routes, rest_card_routes), + ) as create_http, + patch.object(add_a2a_module, "_use_jwt_middleware") as use_jwt, + patch.object( + add_a2a_module, + "add_a2a_routes_to_fastapi", + ) as register_routes, + ): + add_a2a(app, agent, adapter) + + create_jsonrpc.assert_called_once_with(adapter, adapter.agent_interfaces[0]) + create_http.assert_called_once_with(adapter, adapter.agent_interfaces[1]) + assert use_jwt.call_args_list == [ + call(jsonrpc_card_routes + rest_card_routes), + call(jsonrpc_routes), + call(rest_routes), + ] + register_routes.assert_called_once_with( + app, + agent_card_routes=jsonrpc_card_routes + rest_card_routes, + jsonrpc_routes=jsonrpc_routes, + rest_routes=rest_routes, + ) + + +def test_add_a2a_can_skip_jwt_middleware(): + app = FastAPI() + adapter = _adapter() + + with ( + patch.object(add_a2a_module, "_use_jwt_middleware") as use_jwt, + patch.object(add_a2a_module, "add_a2a_routes_to_fastapi"), + ): + add_a2a(app, MagicMock(), adapter, use_jwt_middleware=False) + + use_jwt.assert_not_called() + + +def test_add_a2a_is_exported_from_package(): + assert exported_add_a2a is add_a2a diff --git a/tests/hosting_a2a/test_blob_task_store.py b/tests/hosting_a2a/test_blob_task_store.py new file mode 100644 index 000000000..d3fd26232 --- /dev/null +++ b/tests/hosting_a2a/test_blob_task_store.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from a2a.types import Task, TaskState, TaskStatus +from google.protobuf.message import DecodeError + +from microsoft_agents.hosting.a2a.blob_task_store import BlobTaskStore + + +def _task(task_id: str = "task/1") -> Task: + return Task( + id=task_id, + context_id="context-1", + status=TaskStatus( + state=TaskState.TASK_STATE_WORKING, + ), + ) + + +def _store(): + container_client = MagicMock() + container_client.create_container = AsyncMock() + store = BlobTaskStore(container_client) + store._initialized = True + return store, container_client + + +@pytest.mark.asyncio +async def test_save_uploads_serialized_protobuf(): + store, container_client = _store() + blob_client = MagicMock() + blob_client.upload_blob = AsyncMock() + container_client.get_blob_client.return_value = blob_client + task = _task() + + await store.save(task, context=MagicMock()) + + container_client.get_blob_client.assert_called_once_with("TODOtask%2F1") + blob_client.upload_blob.assert_awaited_once_with( + data=task.SerializeToString(), + overwrite=True, + length=len(task.SerializeToString()), + ) + + +@pytest.mark.asyncio +async def test_get_deserializes_protobuf_task(): + store, container_client = _store() + task = _task() + downloader = SimpleNamespace(readall=AsyncMock(return_value=task.SerializeToString())) + container_client.download_blob = AsyncMock(return_value=downloader) + + result = await store.get(task.id, context=MagicMock()) + + assert result == task + assert result is not task + container_client.download_blob.assert_awaited_once_with( + blob="TODOtask%2F1", + timeout=5, + ) + + +@pytest.mark.asyncio +async def test_download_task_blob_uses_existing_blob_name(): + store, container_client = _store() + task = _task() + downloader = SimpleNamespace(readall=AsyncMock(return_value=task.SerializeToString())) + container_client.download_blob = AsyncMock(return_value=downloader) + + result = await store._download_task_blob("TODOtask%2F1") + + assert result == task + container_client.download_blob.assert_awaited_once_with( + blob="TODOtask%2F1", + timeout=5, + ) + + +@pytest.mark.asyncio +async def test_get_returns_none_when_blob_does_not_exist(): + store, container_client = _store() + container_client.download_blob = AsyncMock(return_value=None) + + assert await store.get("missing", context=MagicMock()) is None + + +@pytest.mark.asyncio +async def test_get_propagates_invalid_protobuf_data(): + store, container_client = _store() + downloader = SimpleNamespace(readall=AsyncMock(return_value=b"not protobuf")) + container_client.download_blob = AsyncMock(return_value=downloader) + + with pytest.raises(DecodeError): + await store.get("invalid", context=MagicMock()) From d7d5a72471eaee899966f5e652a0aa147303638b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 10:31:41 -0700 Subject: [PATCH 23/39] Adding more tests and refining BlobTaskStore --- .../hosting/a2a/a2a_adapter.py | 8 +- .../hosting/a2a/blob_task_store.py | 8 +- .../request_handling/a2a_agent_executor.py | 7 +- .../a2a/request_handling/a2a_http_adapter.py | 8 + .../testing/auth/_mock_connections.py | 1 + tests/hosting_a2a/integration/__init__.py | 2 + tests/hosting_a2a/integration/conftest.py | 82 +++++++ .../hosting_a2a/integration/test_protocol.py | 187 +++++++++++++++ .../request_handling/test_request_handling.py | 73 +++++- tests/hosting_a2a/test_a2a_adapter.py | 58 ++++- tests/hosting_a2a/test_blob_task_store.py | 226 +++++++++++++++++- 11 files changed, 635 insertions(+), 25 deletions(-) create mode 100644 libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_mock_connections.py create mode 100644 tests/hosting_a2a/integration/__init__.py create mode 100644 tests/hosting_a2a/integration/conftest.py create mode 100644 tests/hosting_a2a/integration/test_protocol.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index f3cf305cd..77476ce3e 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -100,12 +100,7 @@ def __init__( self._task_store = task_store or InMemoryTaskStore() self._skills: list[AgentSkill] = skills or [] - self._agent_interfaces: list[AgentInterface] = agent_interfaces or [ - AgentInterface( - url="http://localhost:8000/a2a", - protocol_binding=TransportProtocol.JSONRPC, - ) - ] + self._agent_interfaces: list[AgentInterface] = agent_interfaces or [] self._a2a_request_handler = A2ARequestHandler( self, @@ -316,7 +311,6 @@ async def _on_streaming_response( context_id=message.context_id, artifact=artifact, append=False, - last_chunk=True, ) ) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py index cd8182dc2..c37d27bcf 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py @@ -11,7 +11,8 @@ from a2a.types.a2a_pb2 import ( ListTasksRequest, ListTasksResponse, - Task + Task, + TaskState, ) @@ -83,7 +84,10 @@ async def _ensure_container_exists(self) -> None: def _should_include_task(task: Task, request: ListTasksRequest) -> bool: """Determines whether a task should be included in the list based on the request parameters.""" - if request.status != task.status: + if ( + request.status != TaskState.TASK_STATE_UNSPECIFIED + and request.status != task.status.state + ): return False if request.context_id and request.context_id != task.context_id: return False diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py index c15b0c1a1..7bbc672f2 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py @@ -72,4 +72,9 @@ async def cancel( task_id=task_id or '', context_id=context.context_id or '', ) - await updater.cancel() \ No newline at end of file + await updater.cancel() + + await self._adapter.cancel_agent_turn( + context, + event_queue, + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py index 1fd85b3eb..f03fe0482 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py @@ -40,6 +40,14 @@ async def execute_agent_turn(self, context: RequestContext, event_queue: EventQu :param event_queue: The event queue for the agent turn. """ ... + + async def cancel_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + """Cancel an agent turn given the request context and event queue. + + :param context: The request context for the agent turn. + :param event_queue: The event queue for the agent turn. + """ + ... async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) -> AgentCard: """Process a request for the agent card. diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_mock_connections.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_mock_connections.py new file mode 100644 index 000000000..c79eff991 --- /dev/null +++ b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_mock_connections.py @@ -0,0 +1 @@ +class TestingConnectionManager(Connections): diff --git a/tests/hosting_a2a/integration/__init__.py b/tests/hosting_a2a/integration/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/tests/hosting_a2a/integration/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/tests/hosting_a2a/integration/conftest.py b/tests/hosting_a2a/integration/conftest.py new file mode 100644 index 000000000..b669743b9 --- /dev/null +++ b/tests/hosting_a2a/integration/conftest.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from collections.abc import AsyncIterator + +import httpx +import pytest_asyncio +from a2a.types import AgentInterface, AgentSkill +from a2a.utils.constants import TransportProtocol +from fastapi import FastAPI + +from microsoft_agents.hosting.a2a import A2AAdapter, add_a2a +from microsoft_agents.activity import Activity, ActivityTypes, EndOfConversationCodes +from microsoft_agents.hosting.core import ( + AgentApplication, + ApplicationOptions, + MemoryStorage, + TurnContext, + TurnState, +) +from tests._common.testing_objects import TestingConnectionManager + + +def _create_agent_application() -> AgentApplication[TurnState]: + application = AgentApplication[TurnState]( + options=ApplicationOptions(storage=MemoryStorage()), + connection_manager=TestingConnectionManager(), + ) + + @application.activity("message") + async def on_message(context: TurnContext, _state: TurnState) -> None: + await context.send_activity(f"Echo: {context.activity.text or ''}") + await context.send_activity( + Activity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + ) + ) + + return application + + +@pytest_asyncio.fixture +async def a2a_client() -> AsyncIterator[httpx.AsyncClient]: + """Create an unauthenticated application exposing JSON-RPC and REST A2A routes.""" + + app = FastAPI() + agent = _create_agent_application() + adapter = A2AAdapter( + agent, + agent_card_name="Compatibility Agent", + agent_card_description="A deterministic A2A compatibility test agent.", + agent_card_version="1.0.0", + agent_interfaces=[ + AgentInterface( + url="/rpc", + protocol_binding=TransportProtocol.JSONRPC, + ), + AgentInterface( + url="/rest", + protocol_binding=TransportProtocol.HTTP_JSON, + ), + ], + skills=[ + AgentSkill( + id="echo", + name="Echo", + description="Echoes the incoming text.", + tags=["test"], + examples=["Hello"], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ], + ) + add_a2a(app, agent, adapter=adapter, use_jwt_middleware=False) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + yield client diff --git a/tests/hosting_a2a/integration/test_protocol.py b/tests/hosting_a2a/integration/test_protocol.py new file mode 100644 index 000000000..305d4d27f --- /dev/null +++ b/tests/hosting_a2a/integration/test_protocol.py @@ -0,0 +1,187 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +import uuid + +import httpx +import pytest +from google.protobuf.json_format import MessageToDict + +from a2a.types.a2a_pb2 import ( + CancelTaskRequest, + GetTaskRequest, + ListTasksRequest, + Message, + Part, + Role, + SendMessageRequest, +) + +pytestmark = pytest.mark.filterwarnings( + "ignore:label\\(\\) is deprecated\\. Use is_required\\(\\) or is_repeated\\(\\) instead\\.:DeprecationWarning" +) + + +def _message(text: str = "hello") -> Message: + return Message( + role=Role.ROLE_USER, + message_id=str(uuid.uuid4()), + parts=[Part(text=text)], + ) + + +def _send_request(text: str = "hello") -> SendMessageRequest: + return SendMessageRequest(message=_message(text)) + + +def _rpc_request(method: str, params: object, request_id: str = "request-1") -> dict: + serialized_params = ( + MessageToDict(params) if hasattr(params, "DESCRIPTOR") else params + ) + return { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": serialized_params, + } + + +async def _post_rpc( + client: httpx.AsyncClient, + method: str, + params: object, + request_id: str = "request-1", +) -> dict: + response = await client.post( + "/rpc", + json=_rpc_request(method, params, request_id), + headers={"A2A-Version": "1.0"}, + ) + assert response.status_code == 200 + return response.json() + + +def _task_id(response: dict) -> str: + return response["result"]["task"]["id"] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_agent_card_describes_configured_interfaces_and_skill( + a2a_client: httpx.AsyncClient, +) -> None: + response = await a2a_client.get("/rpc/.well-known/agent-card.json") + + assert response.status_code == 200 + card = response.json() + assert card["name"] == "Compatibility Agent" + assert card["version"] == "1.0.0" + assert {item["url"] for item in card["supportedInterfaces"]} == { + "http://testserver/rpc", + "http://testserver/rest", + } + assert card["skills"][0]["id"] == "echo" + assert card["capabilities"]["streaming"] is True + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jsonrpc_message_send_get_and_list( + a2a_client: httpx.AsyncClient, +) -> None: + response = await _post_rpc( + a2a_client, + "SendMessage", + _send_request("json-rpc"), + ) + + assert response["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED" + task_id = _task_id(response) + context_id = response["result"]["task"]["contextId"] + assert response["result"]["task"]["history"][1]["parts"][0]["text"] == ( + "Echo: json-rpc" + ) + + get_response = await _post_rpc( + a2a_client, + "GetTask", + GetTaskRequest(id=task_id), + request_id="get-task", + ) + assert get_response["result"]["id"] == task_id + assert get_response["result"]["contextId"] == context_id + + list_response = await _post_rpc( + a2a_client, + "ListTasks", + ListTasksRequest(context_id=context_id), + request_id="list-tasks", + ) + assert [task["id"] for task in list_response["result"]["tasks"]] == [task_id] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_rest_message_send_get_and_list_match_jsonrpc_semantics( + a2a_client: httpx.AsyncClient, +) -> None: + request = _send_request("rest") + response = await a2a_client.post( + "/rest/message:send", + json=MessageToDict(request), + headers={"A2A-Version": "1.0"}, + ) + + assert response.status_code == 200 + task = response.json()["task"] + assert task["status"]["state"] == "TASK_STATE_COMPLETED" + task_id = task["id"] + + get_response = await a2a_client.get( + f"/rest/tasks/{task_id}", + headers={"A2A-Version": "1.0"}, + ) + assert get_response.status_code == 200 + assert get_response.json()["id"] == task_id + + list_response = await a2a_client.get( + "/rest/tasks", + headers={"A2A-Version": "1.0"}, + ) + assert list_response.status_code == 200 + assert any(item["id"] == task_id for item in list_response.json()["tasks"]) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jsonrpc_cancel_missing_task_returns_protocol_error( + a2a_client: httpx.AsyncClient, +) -> None: + response = await _post_rpc( + a2a_client, + "CancelTask", + CancelTaskRequest(id="missing-task"), + request_id="cancel-task", + ) + + assert response["id"] == "cancel-task" + assert response["error"]["code"] != 0 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jsonrpc_rejects_unknown_method( + a2a_client: httpx.AsyncClient, +) -> None: + response = await a2a_client.post( + "/rpc", + json=_rpc_request("UnsupportedMethod", {}, request_id="unknown"), + headers={"A2A-Version": "1.0"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "unknown" + assert payload["error"]["code"] == -32601 diff --git a/tests/hosting_a2a/request_handling/test_request_handling.py b/tests/hosting_a2a/request_handling/test_request_handling.py index 3f1b7e641..023a1e684 100644 --- a/tests/hosting_a2a/request_handling/test_request_handling.py +++ b/tests/hosting_a2a/request_handling/test_request_handling.py @@ -92,8 +92,11 @@ async def test_executor_drops_context_without_message(caplog): @pytest.mark.asyncio -async def test_executor_cancel_updates_task_without_calling_adapter(): - adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) +async def test_executor_cancel_updates_task_and_notifies_adapter(): + adapter = SimpleNamespace( + execute_agent_turn=AsyncMock(), + cancel_agent_turn=AsyncMock(), + ) executor = A2AAgentExecutor(adapter) context = SimpleNamespace(task_id="task-1", context_id="context-1") event_queue = AsyncMock(spec=EventQueue) @@ -112,7 +115,71 @@ async def test_executor_cancel_updates_task_without_calling_adapter(): context_id="context-1", ) updater.cancel.assert_awaited_once() - adapter.execute_agent_turn.assert_not_awaited() + adapter.cancel_agent_turn.assert_awaited_once_with(context, event_queue) + + +@pytest.mark.asyncio +async def test_executor_cancel_uses_empty_ids_when_context_ids_are_missing(): + adapter = SimpleNamespace(cancel_agent_turn=AsyncMock()) + executor = A2AAgentExecutor(adapter) + context = SimpleNamespace(task_id=None, context_id=None) + event_queue = AsyncMock(spec=EventQueue) + updater = AsyncMock() + + with patch( + "microsoft_agents.hosting.a2a.request_handling." + "a2a_agent_executor.TaskUpdater", + return_value=updater, + ) as task_updater: + await executor.cancel(context, event_queue) + + task_updater.assert_called_once_with( + event_queue=event_queue, + task_id="", + context_id="", + ) + adapter.cancel_agent_turn.assert_awaited_once_with(context, event_queue) + + +@pytest.mark.asyncio +async def test_executor_cancel_does_not_notify_adapter_when_status_update_fails(): + adapter = SimpleNamespace(cancel_agent_turn=AsyncMock()) + executor = A2AAgentExecutor(adapter) + context = SimpleNamespace(task_id="task-1", context_id="context-1") + event_queue = AsyncMock(spec=EventQueue) + updater = AsyncMock() + updater.cancel.side_effect = RuntimeError("queue failed") + + with patch( + "microsoft_agents.hosting.a2a.request_handling." + "a2a_agent_executor.TaskUpdater", + return_value=updater, + ): + with pytest.raises(RuntimeError, match="queue failed"): + await executor.cancel(context, event_queue) + + adapter.cancel_agent_turn.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_executor_cancel_propagates_adapter_failure(): + adapter = SimpleNamespace( + cancel_agent_turn=AsyncMock(side_effect=RuntimeError("agent failed")) + ) + executor = A2AAgentExecutor(adapter) + context = SimpleNamespace(task_id="task-1", context_id="context-1") + event_queue = AsyncMock(spec=EventQueue) + updater = AsyncMock() + + with patch( + "microsoft_agents.hosting.a2a.request_handling." + "a2a_agent_executor.TaskUpdater", + return_value=updater, + ): + with pytest.raises(RuntimeError, match="agent failed"): + await executor.cancel(context, event_queue) + + updater.cancel.assert_awaited_once() def test_request_handler_uses_adapter_and_updates_agent_card(): diff --git a/tests/hosting_a2a/test_a2a_adapter.py b/tests/hosting_a2a/test_a2a_adapter.py index 55cede688..9df4e7f9b 100644 --- a/tests/hosting_a2a/test_a2a_adapter.py +++ b/tests/hosting_a2a/test_a2a_adapter.py @@ -90,12 +90,7 @@ def test_constructor_uses_defaults_and_creates_request_handler(): adapter = A2AAdapter(_agent()) assert isinstance(adapter._task_store, InMemoryTaskStore) - assert len(adapter.agent_interfaces) == 1 - assert adapter.agent_interfaces[0].url == "http://localhost:8000/a2a" - assert ( - adapter.agent_interfaces[0].protocol_binding - == TransportProtocol.JSONRPC - ) + assert adapter.agent_interfaces == [] assert adapter.skills == [] assert adapter.a2a_request_handler is not None @@ -167,6 +162,55 @@ async def test_execute_agent_turn_converts_message_and_processes_activity(): assert actual_queue is event_queue +@pytest.mark.asyncio +async def test_cancel_agent_turn_processes_user_cancelled_activity(): + identity = ClaimsIdentity({"sub": "agent-1"}) + context = _request_context(identity, include_message=False) + event_queue = MagicMock(spec=EventQueue) + adapter = A2AAdapter(_agent()) + adapter._process_activity_with_a2a = AsyncMock() + + await adapter.cancel_agent_turn(context, event_queue) + + adapter._process_activity_with_a2a.assert_awaited_once() + actual_identity, activity, actual_context, actual_queue = ( + adapter._process_activity_with_a2a.call_args.args + ) + assert actual_identity is identity + assert activity.type == ActivityTypes.end_of_conversation + assert activity.code == EndOfConversationCodes.user_cancelled + assert activity.channel_id == Channels.a2a + assert activity.recipient.id == "assistant" + assert activity.from_property.id == "unknown" + assert actual_context is context + assert actual_queue is event_queue + + +@pytest.mark.asyncio +async def test_cancel_agent_turn_uses_anonymous_identity_by_default(): + context = _request_context(include_message=False) + adapter = A2AAdapter(_agent()) + adapter._process_activity_with_a2a = AsyncMock() + + await adapter.cancel_agent_turn(context, MagicMock(spec=EventQueue)) + + identity = adapter._process_activity_with_a2a.call_args.args[0] + assert isinstance(identity, ClaimsIdentity) + assert identity.allow_anonymous is True + + +@pytest.mark.asyncio +async def test_cancel_agent_turn_rejects_invalid_identity(): + context = _request_context(identity="invalid", include_message=False) + adapter = A2AAdapter(_agent()) + adapter._process_activity_with_a2a = AsyncMock() + + with pytest.raises(RuntimeError, match="Invalid identity"): + await adapter.cancel_agent_turn(context, MagicMock(spec=EventQueue)) + + adapter._process_activity_with_a2a.assert_not_awaited() + + @pytest.mark.asyncio async def test_process_activity_registers_services_and_runs_agent_pipeline(): agent = _agent() @@ -324,7 +368,7 @@ async def test_streaming_response_enqueues_artifact_update(): assert event.context_id == "context-1" assert event.artifact.artifact_id == "artifact-1" assert event.artifact.parts[0].text == "chunk" - assert event.last_chunk is True + assert event.last_chunk is False @pytest.mark.asyncio diff --git a/tests/hosting_a2a/test_blob_task_store.py b/tests/hosting_a2a/test_blob_task_store.py index d3fd26232..bee783f78 100644 --- a/tests/hosting_a2a/test_blob_task_store.py +++ b/tests/hosting_a2a/test_blob_task_store.py @@ -1,22 +1,58 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import asyncio +import importlib from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from a2a.types import Task, TaskState, TaskStatus +from a2a.types import ListTasksRequest, Task, TaskState, TaskStatus from google.protobuf.message import DecodeError from microsoft_agents.hosting.a2a.blob_task_store import BlobTaskStore +blob_task_store_module = importlib.import_module( + "microsoft_agents.hosting.a2a.blob_task_store" +) -def _task(task_id: str = "task/1") -> Task: + +class _AsyncIterator: + def __init__(self, values): + self._values = iter(values) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._values) + except StopIteration as error: + raise StopAsyncIteration from error + + +class _BlobPage: + def __init__(self, blob_names, continuation_token=None): + self._blobs = [ + SimpleNamespace(name=blob_name) for blob_name in blob_names + ] + self.continuation_token = continuation_token + + def __aiter__(self): + return _AsyncIterator(self._blobs) + + +def _task( + task_id: str = "task/1", + *, + context_id: str = "context-1", + state: TaskState = TaskState.TASK_STATE_WORKING, +) -> Task: return Task( id=task_id, - context_id="context-1", + context_id=context_id, status=TaskStatus( - state=TaskState.TASK_STATE_WORKING, + state=state, ), ) @@ -29,6 +65,84 @@ def _store(): return store, container_client +def test_constructor_uses_provided_container_client(): + container_client = MagicMock() + + store = BlobTaskStore(container_client) + + assert store._container_client is container_client + assert store._initialized is False + + +def test_constructor_creates_container_client_from_connection_string(): + blob_service_client = MagicMock() + container_client = MagicMock() + blob_service_client.get_container_client.return_value = container_client + + with patch.object( + blob_task_store_module.BlobServiceClient, + "from_connection_string", + return_value=blob_service_client, + ) as from_connection_string: + store = BlobTaskStore( + data_connection_string="UseDevelopmentStorage=true", + container_name="tasks", + ) + + from_connection_string.assert_called_once_with( + "UseDevelopmentStorage=true" + ) + blob_service_client.get_container_client.assert_called_once_with("tasks") + assert store._container_client is container_client + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"data_connection_string": "connection"}, + {"container_name": "tasks"}, + ], +) +def test_constructor_rejects_invalid_parameter_combinations(kwargs): + with pytest.raises(ValueError, match="Invalid combination"): + BlobTaskStore(**kwargs) + + +@pytest.mark.asyncio +async def test_ensure_container_exists_initializes_only_once(): + container_client = MagicMock() + container_client.create_container = AsyncMock() + store = BlobTaskStore(container_client) + + await asyncio.gather( + store._ensure_container_exists(), + store._ensure_container_exists(), + store._ensure_container_exists(), + ) + + container_client.create_container.assert_awaited_once() + assert store._initialized is True + + +@pytest.mark.parametrize( + ("task_id", "expected"), + [ + ("task-1", "TODOtask-1"), + ("task/1", "TODOtask%2F1"), + ("task with spaces", "TODOtask+with+spaces"), + ("task+plus", "TODOtask%2Bplus"), + ], +) +def test_get_blob_name_encodes_task_id(task_id, expected): + assert BlobTaskStore._get_blob_name(task_id) == expected + + +def test_get_blob_name_rejects_empty_task_id(): + with pytest.raises(ValueError, match="Task ID cannot be empty"): + BlobTaskStore._get_blob_name("") + + @pytest.mark.asyncio async def test_save_uploads_serialized_protobuf(): store, container_client = _store() @@ -47,6 +161,21 @@ async def test_save_uploads_serialized_protobuf(): ) +@pytest.mark.asyncio +async def test_save_initializes_container_before_upload(): + container_client = MagicMock() + container_client.create_container = AsyncMock() + blob_client = MagicMock() + blob_client.upload_blob = AsyncMock() + container_client.get_blob_client.return_value = blob_client + store = BlobTaskStore(container_client) + + await store.save(_task(), context=MagicMock()) + + container_client.create_container.assert_awaited_once() + blob_client.upload_blob.assert_awaited_once() + + @pytest.mark.asyncio async def test_get_deserializes_protobuf_task(): store, container_client = _store() @@ -96,3 +225,90 @@ async def test_get_propagates_invalid_protobuf_data(): with pytest.raises(DecodeError): await store.get("invalid", context=MagicMock()) + + +@pytest.mark.parametrize( + ("params", "expected"), + [ + (ListTasksRequest(), True), + ( + ListTasksRequest(status=TaskState.TASK_STATE_WORKING), + True, + ), + ( + ListTasksRequest(status=TaskState.TASK_STATE_COMPLETED), + False, + ), + ( + ListTasksRequest(context_id="context-1"), + True, + ), + ( + ListTasksRequest(context_id="other-context"), + False, + ), + ], +) +def test_should_include_task_filters_by_status_and_context(params, expected): + assert BlobTaskStore._should_include_task(_task(), params) is expected + + +@pytest.mark.asyncio +async def test_list_returns_filtered_tasks_and_continuation_token(): + store, container_client = _store() + page = _BlobPage( + ["TODOtask-1", "TODOtask-2", "TODOmissing"], + continuation_token="next-page", + ) + items = MagicMock() + items.by_page.return_value = _AsyncIterator([page]) + container_client.list_blobs.return_value = items + matching_task = _task("task-1") + other_context_task = _task("task-2", context_id="other-context") + store._download_task_blob = AsyncMock( + side_effect=[matching_task, other_context_task, None] + ) + params = ListTasksRequest( + status=TaskState.TASK_STATE_WORKING, + context_id="context-1", + page_size=25, + page_token="current-page", + ) + + response = await store.list(params, context=MagicMock()) + + container_client.list_blobs.assert_called_once_with( + name_starts_with="TODO", + results_per_page=25, + ) + items.by_page.assert_called_once_with("current-page") + assert list(response.tasks) == [matching_task] + assert response.next_page_token == "next-page" + assert store._download_task_blob.await_count == 3 + + +@pytest.mark.asyncio +async def test_list_returns_empty_response_for_empty_page(): + store, container_client = _store() + page = _BlobPage([]) + items = MagicMock() + items.by_page.return_value = _AsyncIterator([page]) + container_client.list_blobs.return_value = items + + response = await store.list(ListTasksRequest(), context=MagicMock()) + + assert list(response.tasks) == [] + assert response.next_page_token == "" + + +@pytest.mark.asyncio +async def test_delete_deletes_encoded_blob_name(): + store, container_client = _store() + blob_client = MagicMock() + blob_client.delete_blob = AsyncMock() + container_client.get_blob_client.return_value = blob_client + + await store.delete("task/1", context=MagicMock()) + + container_client.get_blob_client.assert_called_once_with("TODOtask%2F1") + blob_client.delete_blob.assert_awaited_once() From 3af7291e03d6d32d6e573a97567b4bbca4cf2e75 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 10:32:01 -0700 Subject: [PATCH 24/39] Another commit --- test_samples/a2a/README.md | 45 ++++++++++++++++++++ test_samples/a2a/agent/__init__.py | 13 ++++++ test_samples/a2a/agent/a2a.py | 33 ++++++++++++++- test_samples/a2a/agent/agent.py | 35 ++++++++++++++++ test_samples/a2a/agent/main.py | 12 +++--- test_samples/a2a/agent/start_server.py | 58 +++++++++++++------------- test_samples/a2a/client/cli.py | 8 +++- test_samples/a2a/requirements.txt | 4 +- 8 files changed, 168 insertions(+), 40 deletions(-) diff --git a/test_samples/a2a/README.md b/test_samples/a2a/README.md index e69de29bb..6b0c58cbe 100644 --- a/test_samples/a2a/README.md +++ b/test_samples/a2a/README.md @@ -0,0 +1,45 @@ +# A2A echo sample + +This sample defines a custom Microsoft 365 Agents SDK agent, adds A2A +capabilities, and hosts it with FastAPI. + +## Install + +From this directory: + +```powershell +pip install -r requirements.txt +``` + +When developing from this repository, install the SDK libraries in editable +mode using `scripts\dev_setup.ps1`. + +## Run the agent + +From the repository root: + +```powershell +python -m test_samples.a2a.agent.main +``` + +The server listens on `http://127.0.0.1:41241` by default. Its endpoints are: + +- Agent card: `http://127.0.0.1:41241/a2a/.well-known/agent-card.json` +- JSON-RPC: `http://127.0.0.1:41241/a2a` +- Health check: `http://127.0.0.1:41241/health` + +Set `HOST` or `PORT` to override the defaults. + +## Run the client + +In another terminal: + +```powershell +python -m test_samples.a2a.client.cli --url http://127.0.0.1:41241/a2a +``` + +Enter a message to receive an echo response. Type `/quit` to exit. + +JWT authorization is intentionally disabled for this local sample. Production +applications should configure an `AgentAuthConfiguration` and enable the A2A +JWT middleware. \ No newline at end of file diff --git a/test_samples/a2a/agent/__init__.py b/test_samples/a2a/agent/__init__.py index e69de29bb..9989a770a 100644 --- a/test_samples/a2a/agent/__init__.py +++ b/test_samples/a2a/agent/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .a2a import A2A_ADAPTER +from .agent import AGENT, EchoAgent +from .start_server import create_app + +__all__ = [ + "A2A_ADAPTER", + "AGENT", + "EchoAgent", + "create_app", +] \ No newline at end of file diff --git a/test_samples/a2a/agent/a2a.py b/test_samples/a2a/agent/a2a.py index 47f0c4650..5b8c84b09 100644 --- a/test_samples/a2a/agent/a2a.py +++ b/test_samples/a2a/agent/a2a.py @@ -1,5 +1,34 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from a2a.types import AgentInterface, AgentSkill +from a2a.utils.constants import TransportProtocol + from microsoft_agents.hosting.a2a import A2AAdapter -adapter = A2AAdapter( - +from .agent import AGENT + + +A2A_ADAPTER = A2AAdapter( + AGENT, + agent_card_name="SDK Echo Agent", + agent_card_description="A simple Microsoft 365 Agents SDK A2A sample.", + agent_card_version="1.0.0", + agent_interfaces=[ + AgentInterface( + url="/a2a", + protocol_binding=TransportProtocol.JSONRPC, + ) + ], + skills=[ + AgentSkill( + id="echo", + name="Echo", + description="Echoes a text message back to the caller.", + tags=["sample", "echo"], + examples=["Hello", "Repeat this message"], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ], ) \ No newline at end of file diff --git a/test_samples/a2a/agent/agent.py b/test_samples/a2a/agent/agent.py index e69de29bb..7b49333ce 100644 --- a/test_samples/a2a/agent/agent.py +++ b/test_samples/a2a/agent/agent.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + EndOfConversationCodes, + TurnContextProtocol, +) +from microsoft_agents.hosting.core import ActivityHandler + + +class EchoAgent(ActivityHandler): + """A small SDK agent that echoes each A2A message.""" + + async def on_message_activity(self, turn_context: TurnContextProtocol): + text = (turn_context.activity.text or "").strip() + + if text.lower() == "/help": + response = "Send me a message and I will echo it back." + elif text: + response = f"Echo: {text}" + else: + response = "I received an empty message." + + await turn_context.send_activity(response) + await turn_context.send_activity( + Activity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + ) + ) + + +AGENT = EchoAgent() \ No newline at end of file diff --git a/test_samples/a2a/agent/main.py b/test_samples/a2a/agent/main.py index 9139fe33b..fd6c91a7b 100644 --- a/test_samples/a2a/agent/main.py +++ b/test_samples/a2a/agent/main.py @@ -1,10 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .agent import AGENT_APP, CONNECTION_MANAGER -from .start_server import start_server +from .start_server import create_app, start_server -start_server( - agent_application=AGENT_APP, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), -) +app = create_app() + + +if __name__ == "__main__": + start_server(app) diff --git a/test_samples/a2a/agent/start_server.py b/test_samples/a2a/agent/start_server.py index 971cb5c32..78bba680c 100644 --- a/test_samples/a2a/agent/start_server.py +++ b/test_samples/a2a/agent/start_server.py @@ -6,37 +6,37 @@ import uvicorn from fastapi import FastAPI -from microsoft_agents.hosting.core import ( - AgentApplication, - AgentAuthConfiguration, -) -from microsoft_agents.hosting.fastapi import ( - CloudAdapter, - JwtAuthorizationMiddleware, - start_agent_process -) - from microsoft_agents.hosting.a2a import add_a2a -def start_server( - agent_application: AgentApplication, - auth_configuration: AgentAuthConfiguration, -) -> None: - """Start the FastAPI server with the given agent application and authentication configuration. - - :param agent_application: The agent application to be hosted by the server. - :param auth_configuration: The authentication configuration for the agent. - """ - - app = FastAPI(title="Empty Agent Sample", version="1.0.0") - app.add_middleware(JwtAuthorizationMiddleware) - app.state.agent_configuration = ( - auth_configuration.get_default_connection_configuration() +from .a2a import A2A_ADAPTER +from .agent import AGENT + + +def create_app() -> FastAPI: + """Create the FastAPI application and register the A2A routes.""" + + app = FastAPI(title="SDK A2A Echo Agent", version="1.0.0") + + # Authentication is disabled only to keep this local sample simple. + add_a2a( + app, + AGENT, + adapter=A2A_ADAPTER, + use_jwt_middleware=False, ) - # Here we set use_jwt_middleware to False because we have already applied this - # middleware at the app-level above - add_a2a(app, agent_application, use_jwt_middleware=False) + @app.get("/health") + async def health(): + return {"status": "ok"} + + return app + + +def start_server(app: FastAPI | None = None) -> None: + """Start the local A2A server.""" - port = int(environ.get("PORT", 3978)) - uvicorn.run(app, host="127.0.0.1", port=port) \ No newline at end of file + uvicorn.run( + app or create_app(), + host=environ.get("HOST", "127.0.0.1"), + port=int(environ.get("PORT", "41241")), + ) \ No newline at end of file diff --git a/test_samples/a2a/client/cli.py b/test_samples/a2a/client/cli.py index 01746b86b..30c3468bf 100644 --- a/test_samples/a2a/client/cli.py +++ b/test_samples/a2a/client/cli.py @@ -30,6 +30,12 @@ async def _handle_stream( current_task_id = event.task.id print('--- Task Started ---') print(f'Task [state={TaskState.Name(event.task.status.state)}]') + elif event.HasField('status_update'): + current_task_id = event.status_update.task_id + print('--- Task Started ---') + elif event.HasField('artifact_update'): + current_task_id = event.artifact_update.task_id + print('--- Task Started ---') else: raise ValueError(f'Unexpected first event: {event}') @@ -66,7 +72,7 @@ async def main() -> None: """Run the A2A terminal client.""" parser = argparse.ArgumentParser(description='A2A Terminal Client') parser.add_argument( - '--url', default='http://127.0.0.1:41241', help='Agent base URL' + '--url', default='http://127.0.0.1:41241/a2a', help='Agent base URL' ) parser.add_argument( '--transport', diff --git a/test_samples/a2a/requirements.txt b/test_samples/a2a/requirements.txt index d0ba9dfa1..b0cbd2946 100644 --- a/test_samples/a2a/requirements.txt +++ b/test_samples/a2a/requirements.txt @@ -1,3 +1,3 @@ -python-dotenv microsoft-agents-hosting-a2a -a2a-sdk[fastapi] \ No newline at end of file +a2a-sdk[fastapi] +uvicorn \ No newline at end of file From f7dbfd9911abdd4b8762ce53ad584e4fc59a383a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 11:43:48 -0700 Subject: [PATCH 25/39] Formatting --- .../microsoft_agents/hosting/a2a/__init__.py | 3 +- .../hosting/a2a/a2a_adapter.py | 41 ++--- .../hosting/a2a/a2a_agent_extension.py | 1 + .../hosting/a2a/a2a_turn_context.py | 3 +- .../hosting/a2a/activity/a2a_activity.py | 78 +++++---- .../hosting/a2a/activity/utils.py | 18 +- .../microsoft_agents/hosting/a2a/add_a2a.py | 33 +++- .../hosting/a2a/blob_task_store.py | 36 ++-- .../hosting/a2a/request_handling/__init__.py | 2 +- .../request_handling/a2a_agent_executor.py | 7 +- .../a2a/request_handling/a2a_http_adapter.py | 18 +- .../hosting/a2a/server/routes.py | 18 +- .../server/sdk_server_call_context_builder.py | 1 + .../testing/auth/_mock_connections.py | 1 - tests/hosting_a2a/activity/test_utils.py | 23 +-- tests/hosting_a2a/integration/conftest.py | 16 +- .../hosting_a2a/integration/test_protocol.py | 157 ++++++++++++++++++ tests/hosting_a2a/test_a2a_adapter.py | 11 +- tests/hosting_a2a/test_a2a_agent_extension.py | 1 - tests/hosting_a2a/test_blob_task_store.py | 16 +- 20 files changed, 343 insertions(+), 141 deletions(-) delete mode 100644 libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_mock_connections.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py index 8baf40704..e78c73430 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py @@ -6,7 +6,7 @@ from .server import ( create_jsonrpc_routes, create_rest_routes, - SDKServerCallContextBuilder + SDKServerCallContextBuilder, ) from .a2a_adapter import A2AAdapter from .a2a_agent_extension import A2AAgentExtension @@ -14,7 +14,6 @@ from .a2a_turn_context import A2ATurnContext from .add_a2a import add_a2a - __all__ = [ "A2AActivity", "A2AHttpAdapter", diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index 77476ce3e..9f07758f4 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -79,7 +79,7 @@ def __init__( agent_card_version: str = "0.0.0", agent_interfaces: list[AgentInterface] | None = None, skills: list[AgentSkill] | None = None, - task_store: TaskStore | None = None + task_store: TaskStore | None = None, ): """Initializes the A2AAdapter with the given agent and optional task store. @@ -101,7 +101,7 @@ def __init__( self._skills: list[AgentSkill] = skills or [] self._agent_interfaces: list[AgentInterface] = agent_interfaces or [] - + self._a2a_request_handler = A2ARequestHandler( self, task_store=self._task_store, @@ -159,7 +159,9 @@ async def execute_agent_turn( event_queue, ) - async def cancel_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + async def cancel_agent_turn( + self, context: RequestContext, event_queue: EventQueue + ) -> None: """Cancel an ongoing agent turn given the request context and event queue. :param context: The request context for the agent turn. @@ -187,7 +189,6 @@ async def cancel_agent_turn(self, context: RequestContext, event_queue: EventQue event_queue, ) - def _create_turn_context( self, claims_identity: ClaimsIdentity, @@ -349,10 +350,7 @@ async def _on_end_of_conversation_response( if isinstance(activity.value, dict): artifact = utils.create_artifact_from_data( - activity.value, - name="Result", - description="Task completion result", - media_type="application/json", + activity.value, name="Result", description="Task completion result" ) await event_queue.enqueue_event( TaskArtifactUpdateEvent( @@ -416,11 +414,13 @@ def _get_basic_agent_card(self) -> AgentCard: supported_interfaces=[], ) - async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) -> AgentCard: + async def get_agent_card( + self, request: HttpRequestProtocol, path_prefix: str + ) -> AgentCard: """Get the agent card for the current agent, potentially customized based on the request. Set as asynchronous because in some implementations, fetching or customizing the agent card might involve I/O operations, such as querying a database or an external service. - + :param request: The HTTP request object conforming to HttpRequestProtocol. :param path_prefix: The prefix to be used for constructing the agent interface URL. :return: An AgentCard instance representing the agent's capabilities. @@ -429,7 +429,7 @@ async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) - url_parts = urlsplit(request.url) request_origin = f"{url_parts.scheme}://{url_parts.netloc}" - + if not self._agent_interfaces: agent_card.supported_interfaces.append( AgentInterface( @@ -448,13 +448,17 @@ async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) - if interface_url.startswith("/"): interface_url = f"{request_origin}{interface_url}" - agent_card.supported_interfaces.append(AgentInterface( - protocol_binding=agent_interface.protocol_binding, - url=interface_url, - protocol_version="1.0", - )) + agent_card.supported_interfaces.append( + AgentInterface( + protocol_binding=agent_interface.protocol_binding, + url=interface_url, + protocol_version="1.0", + ) + ) else: - logger.info("Unsupported protocol: %s", agent_interface.protocol_binding) + logger.info( + "Unsupported protocol: %s", agent_interface.protocol_binding + ) if self._skills: for skill_info in self._skills: @@ -471,9 +475,8 @@ async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) - ) return agent_card - async def update_activity(self, context: TurnContext, activity: Activity) -> None: raise NotImplementedError("A2AAdapter.update_activity is not implemented.") async def delete_activity(self, context: TurnContext, activity_id: str) -> None: - raise NotImplementedError("A2AAdapter.delete_activity is not implemented.") \ No newline at end of file + raise NotImplementedError("A2AAdapter.delete_activity is not implemented.") diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py index 94feb1340..1c5f30073 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py @@ -50,6 +50,7 @@ def _wrap_decorator( def __call(func: A2ARouteHandler[StateT]) -> RouteHandler[StateT]: return decorator(wrap_a2a_route_handler(func, self._app)) + return __call def message( diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py index 80730d556..357457f48 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py @@ -16,6 +16,7 @@ from .activity import A2AActivity from .a2a_client import A2AClient + class A2ATurnContext(TurnContext): """A context object for handling A2A-specific turn functionality. @@ -31,7 +32,7 @@ def __init__( identity: ClaimsIdentity | None = None, ) -> None: """Initialize the A2A turn context. - + :param adapter_or_context: The channel service adapter or existing turn context. :param app: The agent application instance. :param activity: The activity for the turn context. diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index 7b63df1dd..0b03ccd6a 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -36,13 +36,18 @@ _DEFAULT_USER_ID = "unknown" + class A2AActivity(Activity): """A2A-aware :class:`Activity` subclass exposing A2A protocol data helpers.""" - channel_id: ChannelId = Field(default_factory=lambda: ChannelId(Channels.a2a), frozen=True) + channel_id: ChannelId = Field( + default_factory=lambda: ChannelId(Channels.a2a), frozen=True + ) @staticmethod - def from_message(request_id: str, task_id: str | None, message: Message) -> A2AActivity: + def from_message( + request_id: str, task_id: str | None, message: Message + ) -> A2AActivity: """Create an A2AActivity from a Message object. :param request_id: The request ID for the activity. @@ -55,24 +60,21 @@ def from_message(request_id: str, task_id: str | None, message: Message) -> A2AA raise ValueError("Task ID is required.") task_id = task_id or message.task_id - - activity = A2AActivity._create_activity( - task_id, - message.parts, - True, - True - ) - activity.request_id = request_id or str(uuid4()) # formatting + activity = A2AActivity._create_activity(task_id, message.parts, True, True) + + activity.request_id = request_id or str(uuid4()) # formatting activity.channel_data = message - message.context_id = message.context_id or str(uuid4()) # Formatting + message.context_id = message.context_id or str(uuid4()) # Formatting message.task_id = task_id return activity - def to_message(self, context_id: str, task_id: str, include_entities: bool = True) -> Message: + def to_message( + self, context_id: str, task_id: str, include_entities: bool = True + ) -> Message: """Convert the activity to a Message object. - + :param context_id: The context ID for the message. :param task_id: The task ID for the message. :param include_entities: Whether to include entities in the message. Defaults to True. @@ -80,7 +82,9 @@ def to_message(self, context_id: str, task_id: str, include_entities: bool = Tru """ return utils.create_message(context_id, task_id, self, include_entities) - def to_artifact(self, artifact_id: str | None = None, include_entities: bool = True) -> Artifact: + def to_artifact( + self, artifact_id: str | None = None, include_entities: bool = True + ) -> Artifact: """Convert the activity to an Artifact object. :param artifact_id: The ID of the artifact. If None, a new ID will be generated. @@ -101,10 +105,10 @@ def _create_activity( conversation_id: str, parts: Iterable[Part], is_ingress: bool, - is_streaming: bool + is_streaming: bool, ) -> A2AActivity: """Create an Activity representing an A2A concept. - + :param conversation_id: The ID of the conversation. :param parts: The parts to include in the activity. :param is_ingress: Whether the activity is incoming. @@ -118,7 +122,9 @@ def _create_activity( activity = A2AActivity( type=ActivityTypes.message, id=str(uuid4()), - delivery_mode=DeliveryModes.stream if is_streaming else DeliveryModes.expect_replies, + delivery_mode=( + DeliveryModes.stream if is_streaming else DeliveryModes.expect_replies + ), conversation=ConversationAccount(id=conversation_id), recipient=agent if is_ingress else user, from_property=user if is_ingress else agent, @@ -132,22 +138,28 @@ def _create_activity( else: activity.text += part.text elif part.url: - activity.attachments.append(Attachment( - content_type=part.media_type, - content_url=part.url, - name=part.filename, - )) + activity.attachments.append( + Attachment( + content_type=part.media_type, + content_url=part.url, + name=part.filename, + ) + ) elif part.raw: - activity.attachments.append(Attachment( - content_type=part.media_type, - content=part.raw, - name=part.filename, - )) + activity.attachments.append( + Attachment( + content_type=part.media_type, + content=part.raw, + name=part.filename, + ) + ) elif part.data: - activity.attachments.append(Attachment( - content_type="application/json", - content=json.dumps(MessageToDict(part.data)), - name="A2A DataPart", - )) + activity.attachments.append( + Attachment( + content_type="application/json", + content=json.dumps(MessageToDict(part.data)), + name="A2A DataPart", + ) + ) - return activity \ No newline at end of file + return activity diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 2b4a64b7a..64cd69bcd 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -82,14 +82,13 @@ def activity_to_artifact( artifact.parts.append( Part( metadata=_get_a2a_metadata(entity), - data=_to_protobuf_value( - entity.model_dump(exclude_none=True) - ), + data=_to_protobuf_value(entity.model_dump(exclude_none=True)), ) ) return artifact + @lru_cache def _try_get_json_schema(data_type: type) -> dict[str, Any] | None: try: @@ -99,6 +98,7 @@ def _try_get_json_schema(data_type: type) -> dict[str, Any] | None: except PydanticSchemaGenerationError: return None + def _get_a2a_metadata(data: object) -> dict: """Convert the given data to A2A metadata. @@ -117,27 +117,23 @@ def _get_a2a_metadata(data: object) -> dict: if schema is not None: metadata["schema"] = schema - return metadata + return metadata + def create_artifact_from_data( data: dict, name: str | None = None, description: str | None = None, - media_type: str | None = None, artifact_id: str | None = None, -) -> Artifact | None: +) -> Artifact: """Create an Artifact object from the given data. :param data: The data to include in the artifact. :param name: The name of the artifact. Defaults to None. :param description: The description of the artifact. Defaults to None. - :param media_type: The media type of the artifact. Defaults to None. :param artifact_id: The ID of the artifact. If None, a new ID will be generated. - :return: An Artifact object representing the data, or None if the data is None. + :return: An Artifact object representing the data. """ - if data is None: - return None - return Artifact( artifact_id=artifact_id if artifact_id else str(uuid4()), # check .NET name=name, diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index fd5086d22..3c1c2ab59 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -15,9 +15,10 @@ create_jsonrpc_routes, create_rest_routes, create_agent_card_routes, - use_jwt_middleware as _use_jwt_middleware + use_jwt_middleware as _use_jwt_middleware, ) + def _create_jsonrpc_interface_routes( adapter: A2AAdapter, interface: AgentInterface, @@ -28,23 +29,33 @@ def _create_jsonrpc_interface_routes( :param interface: The agent interface to add routes for. """ - jsonrpc_routes = create_jsonrpc_routes(adapter.a2a_request_handler, rpc_url=interface.url) - agent_card_routes = create_agent_card_routes(adapter.get_agent_card, f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}") + jsonrpc_routes = create_jsonrpc_routes( + adapter.a2a_request_handler, rpc_url=interface.url + ) + agent_card_routes = create_agent_card_routes( + adapter.get_agent_card, f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}" + ) return jsonrpc_routes, agent_card_routes + def _create_http_interface_routes( adapter: A2AAdapter, interface: AgentInterface, ) -> tuple[list[BaseRoute], list[BaseRoute]]: """Create HTTP interface routes for the given agent interface. - + :param adapter: The A2AAdapter instance used to handle requests. :param interface: The agent interface to add routes for. """ - http_routes = create_rest_routes(adapter.a2a_request_handler, path_prefix=interface.url) - agent_card_routes = create_agent_card_routes(adapter.get_agent_card, f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}") + http_routes = create_rest_routes( + adapter.a2a_request_handler, path_prefix=interface.url + ) + agent_card_routes = create_agent_card_routes( + adapter.get_agent_card, f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}" + ) return http_routes, agent_card_routes + def add_a2a( app: FastAPI, agent: Agent, @@ -70,11 +81,15 @@ def add_a2a( for interface in interfaces: if interface.protocol_binding == TransportProtocol.JSONRPC: - _jsonrpc_routes, _agent_card_routes = _create_jsonrpc_interface_routes(adapter, interface) + _jsonrpc_routes, _agent_card_routes = _create_jsonrpc_interface_routes( + adapter, interface + ) jsonrpc_routes.extend(_jsonrpc_routes) agent_card_routes.extend(_agent_card_routes) elif interface.protocol_binding == TransportProtocol.HTTP_JSON: - _http_routes, _agent_card_routes = _create_http_interface_routes(adapter, interface) + _http_routes, _agent_card_routes = _create_http_interface_routes( + adapter, interface + ) rest_routes.extend(_http_routes) agent_card_routes.extend(_agent_card_routes) @@ -88,4 +103,4 @@ def add_a2a( agent_card_routes=agent_card_routes, jsonrpc_routes=jsonrpc_routes, rest_routes=rest_routes, - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py index c37d27bcf..b9e5419c0 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py @@ -27,6 +27,7 @@ _TASK_PREFIX = "TODO" + class BlobTaskStore(TaskStore): """A task store implementation backed by a blob storage.""" @@ -50,7 +51,7 @@ def __init__( container_name: str | None = None, ) -> None: """Initializes the blob task store with either a container client or a connection string and container name. - + :param container_client: An optional ContainerClient instance for accessing the blob storage. :param data_connection_string: An optional connection string for the blob storage. :param container_name: An optional container name within the blob storage. @@ -63,8 +64,12 @@ def __init__( self._container_client = container_client return elif data_connection_string is not None and container_name is not None: - blob_service_client = BlobServiceClient.from_connection_string(data_connection_string) - self._container_client = blob_service_client.get_container_client(container_name) + blob_service_client = BlobServiceClient.from_connection_string( + data_connection_string + ) + self._container_client = blob_service_client.get_container_client( + container_name + ) return else: raise ValueError("Invalid combination of parameters provided.") @@ -76,7 +81,8 @@ async def _ensure_container_exists(self) -> None: async with self._init_lock: if not self._initialized: await ignore_error( - self._container_client.create_container(), is_status_code_error(409) + self._container_client.create_container(), + is_status_code_error(409), ) self._initialized = True @@ -100,7 +106,7 @@ def _get_blob_name(task_id: str) -> str: if not task_id: raise ValueError("Task ID cannot be empty.") return f"{_TASK_PREFIX}{urllib.parse.quote_plus(task_id)}" - + async def save(self, task: Task, context: ServerCallContext) -> None: """Saves or updates a task in the blob store.""" await self._ensure_container_exists() @@ -117,7 +123,7 @@ async def save(self, task: Task, context: ServerCallContext) -> None: async def get(self, task_id: str, context: ServerCallContext) -> Task | None: """Retrieves a task from the blob store by its ID. - + :param task_id: The ID of the task to retrieve. :param context: The server call context. :return: The task if found, otherwise None. @@ -126,9 +132,11 @@ async def get(self, task_id: str, context: ServerCallContext) -> Task | None: return await self._download_task(task_id) - async def list(self, params: ListTasksRequest, context: ServerCallContext) -> ListTasksResponse: + async def list( + self, params: ListTasksRequest, context: ServerCallContext + ) -> ListTasksResponse: """Retrives a list of tasks from the store. - + :param params: The parameters for listing tasks. :param context: The server call context. :return: A response containing the list of tasks. @@ -138,8 +146,7 @@ async def list(self, params: ListTasksRequest, context: ServerCallContext) -> Li tasks: list[Task] = [] items = self._container_client.list_blobs( - name_starts_with=_TASK_PREFIX, - results_per_page=params.page_size + name_starts_with=_TASK_PREFIX, results_per_page=params.page_size ) iterator = items.by_page(params.page_token) @@ -150,7 +157,7 @@ async def list(self, params: ListTasksRequest, context: ServerCallContext) -> Li next_page_token = getattr(first_page, "continuation_token", None) - # TODO -> + # TODO -> async for blob in first_page: task = await self._download_task_blob(blob.name) if task and self._should_include_task(task, params): @@ -158,12 +165,9 @@ async def list(self, params: ListTasksRequest, context: ServerCallContext) -> Li return ListTasksResponse(tasks=tasks, next_page_token=next_page_token) - - - async def delete(self, task_id: str, context: ServerCallContext) -> None: """Deletes a task from the blob store by its ID. - + :param task_id: The ID of the task to delete. :param context: The server call context.s """ @@ -187,4 +191,4 @@ async def _download_task_blob(self, blob_name: str) -> Task | None: task = Task() task.ParseFromString(serialized_task) - return task \ No newline at end of file + return task diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py index b1364e8e4..94fd72583 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/__init__.py @@ -9,4 +9,4 @@ "A2AAgentExecutor", "A2AHttpAdapter", "A2ARequestHandler", -] \ No newline at end of file +] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py index 7bbc672f2..a0c24f5dc 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_agent_executor.py @@ -13,6 +13,7 @@ logger = logging.getLogger(__name__) + class A2AAgentExecutor(AgentExecutor): """Executor for handling A2A agent requests.""" @@ -69,12 +70,12 @@ async def cancel( updater = TaskUpdater( event_queue=event_queue, - task_id=task_id or '', - context_id=context.context_id or '', + task_id=task_id or "", + context_id=context.context_id or "", ) await updater.cancel() await self._adapter.cancel_agent_turn( context, event_queue, - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py index f03fe0482..5e57054fd 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_http_adapter.py @@ -33,27 +33,33 @@ def a2a_request_handler(self) -> RequestHandler: """Get the A2A request handler.""" ... - async def execute_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + async def execute_agent_turn( + self, context: RequestContext, event_queue: EventQueue + ) -> None: """Execute an agent turn given the request context and event queue. - + :param context: The request context for the agent turn. :param event_queue: The event queue for the agent turn. """ ... - async def cancel_agent_turn(self, context: RequestContext, event_queue: EventQueue) -> None: + async def cancel_agent_turn( + self, context: RequestContext, event_queue: EventQueue + ) -> None: """Cancel an agent turn given the request context and event queue. :param context: The request context for the agent turn. :param event_queue: The event queue for the agent turn. """ ... - - async def get_agent_card(self, request: HttpRequestProtocol, path_prefix: str) -> AgentCard: + + async def get_agent_card( + self, request: HttpRequestProtocol, path_prefix: str + ) -> AgentCard: """Process a request for the agent card. :param request: The HTTP request for the agent card. :param path_prefix: The path prefix to be used in the agent card URL. :return: The agent card for the agent. """ - ... \ No newline at end of file + ... diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index a6cb8e025..4822bf944 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -18,7 +18,9 @@ from microsoft_agents.hosting.core import HttpRequestProtocol from microsoft_agents.hosting.fastapi import JwtAuthorizationMiddleware -from microsoft_agents.hosting.fastapi._fastapi_request_adapter import FastApiRequestAdapter +from microsoft_agents.hosting.fastapi._fastapi_request_adapter import ( + FastApiRequestAdapter, +) from .sdk_server_call_context_builder import SDKServerCallContextBuilder @@ -62,6 +64,7 @@ def create_rest_routes( path_prefix=path_prefix, ) + def create_agent_card_routes( get_agent_card: Callable[[HttpRequestProtocol, str], Awaitable[AgentCard]], card_url: str = AGENT_CARD_WELL_KNOWN_PATH, @@ -87,17 +90,12 @@ async def _get_agent_card(request: Request) -> Response: card = await get_agent_card(FastApiRequestAdapter(request), prefix) return JSONResponse(agent_card_to_dict(card)) - return [ - Route( - path=card_url, - endpoint=_get_agent_card, - methods=['GET'] - ) - ] + return [Route(path=card_url, endpoint=_get_agent_card, methods=["GET"])] + def use_jwt_middleware(routes: Sequence[BaseRoute]) -> None: """Wrap all routes with JWT authorization middleware. - + :param routes: A list of BaseRoute objects to wrap with JWT authorization middleware. """ wrapped: set[int] = set() @@ -112,4 +110,4 @@ def wrap(route: BaseRoute) -> None: wrapped.add(id(route)) for route in routes: - wrap(route) \ No newline at end of file + wrap(route) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py index cbf8af33e..c99e11fe2 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/sdk_server_call_context_builder.py @@ -12,6 +12,7 @@ from ._constants import _CLAIMS_IDENTITY_KEY + class SDKServerCallContextBuilder(DefaultServerCallContextBuilder): """A default implementation of ServerCallContextBuilder.""" diff --git a/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_mock_connections.py b/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_mock_connections.py deleted file mode 100644 index c79eff991..000000000 --- a/libraries/microsoft-agents-testing/microsoft_agents/testing/auth/_mock_connections.py +++ /dev/null @@ -1 +0,0 @@ -class TestingConnectionManager(Connections): diff --git a/tests/hosting_a2a/activity/test_utils.py b/tests/hosting_a2a/activity/test_utils.py index f04221961..bd0637d9a 100644 --- a/tests/hosting_a2a/activity/test_utils.py +++ b/tests/hosting_a2a/activity/test_utils.py @@ -90,16 +90,11 @@ def test_activity_to_artifact_rejects_unsupported_attachment_content(): utils.activity_to_artifact(activity) -def test_create_artifact_from_data_returns_none_for_none(): - assert utils.create_artifact_from_data(None) is None - - def test_create_artifact_from_data_populates_artifact(): artifact = utils.create_artifact_from_data( {"answer": 42}, name="result", description="The result", - media_type="application/json", artifact_id="artifact-1", ) @@ -115,6 +110,17 @@ def test_create_artifact_from_data_populates_artifact(): } +def test_create_artifact_from_data_generates_artifact_id(): + artifact = utils.create_artifact_from_data({"answer": 42}) + + assert artifact.artifact_id + + +def test_create_artifact_from_data_rejects_none(): + with pytest.raises(ValueError, match="Data cannot be None"): + utils.create_artifact_from_data(None) + + def test_get_a2a_metadata_rejects_none(): with pytest.raises(ValueError, match="Data cannot be None"): utils._get_a2a_metadata(None) @@ -167,16 +173,13 @@ def test_get_task_state_maps_input_hint(): == TaskState.TASK_STATE_INPUT_REQUIRED ) assert ( - utils.get_task_state(Activity(type="message")) - == TaskState.TASK_STATE_WORKING + utils.get_task_state(Activity(type="message")) == TaskState.TASK_STATE_WORKING ) def test_get_incoming_message_returns_a2a_message(): message = Message(message_id="message-1", role=Role.ROLE_USER) - context = SimpleNamespace( - activity=Activity(type="message", channel_data=message) - ) + context = SimpleNamespace(activity=Activity(type="message", channel_data=message)) assert utils.get_incoming_message(context) is message diff --git a/tests/hosting_a2a/integration/conftest.py b/tests/hosting_a2a/integration/conftest.py index b669743b9..d6f3312d2 100644 --- a/tests/hosting_a2a/integration/conftest.py +++ b/tests/hosting_a2a/integration/conftest.py @@ -20,6 +20,9 @@ ) from tests._common.testing_objects import TestingConnectionManager +STREAMING_TRIGGER_TEXT = "stream-me" +"""Message text that causes the test agent to emit chunked streaming updates.""" + def _create_agent_application() -> AgentApplication[TurnState]: application = AgentApplication[TurnState]( @@ -29,7 +32,18 @@ def _create_agent_application() -> AgentApplication[TurnState]: @application.activity("message") async def on_message(context: TurnContext, _state: TurnState) -> None: - await context.send_activity(f"Echo: {context.activity.text or ''}") + text = context.activity.text or "" + if text == STREAMING_TRIGGER_TEXT: + # Exercise the chunked streaming API so that informative and + # partial-content updates surface as A2A task status/artifact + # update events. + streaming_response = context.streaming_response + streaming_response.queue_informative_update("Thinking...") + streaming_response.queue_text_chunk("Echo: ") + streaming_response.queue_text_chunk(text) + await streaming_response.end_stream() + else: + await context.send_activity(f"Echo: {text}") await context.send_activity( Activity( type=ActivityTypes.end_of_conversation, diff --git a/tests/hosting_a2a/integration/test_protocol.py b/tests/hosting_a2a/integration/test_protocol.py index 305d4d27f..c6fe62ff6 100644 --- a/tests/hosting_a2a/integration/test_protocol.py +++ b/tests/hosting_a2a/integration/test_protocol.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import uuid import httpx @@ -11,6 +12,7 @@ from a2a.types.a2a_pb2 import ( CancelTaskRequest, + GetExtendedAgentCardRequest, GetTaskRequest, ListTasksRequest, Message, @@ -19,6 +21,8 @@ SendMessageRequest, ) +from .conftest import STREAMING_TRIGGER_TEXT + pytestmark = pytest.mark.filterwarnings( "ignore:label\\(\\) is deprecated\\. Use is_required\\(\\) or is_repeated\\(\\) instead\\.:DeprecationWarning" ) @@ -63,6 +67,26 @@ async def _post_rpc( return response.json() +async def _stream_rpc( + client: httpx.AsyncClient, + method: str, + params: object, + request_id: str = "request-1", +) -> list[dict]: + events: list[dict] = [] + async with client.stream( + "POST", + "/rpc", + json=_rpc_request(method, params, request_id), + headers={"A2A-Version": "1.0", "Accept": "text/event-stream"}, + ) as response: + assert response.status_code == 200 + async for line in response.aiter_lines(): + if line.startswith("data:"): + events.append(json.loads(line[len("data:") :].strip())) + return events + + def _task_id(response: dict) -> str: return response["result"]["task"]["id"] @@ -185,3 +209,136 @@ async def test_jsonrpc_rejects_unknown_method( payload = response.json() assert payload["id"] == "unknown" assert payload["error"]["code"] == -32601 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jsonrpc_streaming_message_emits_ordered_task_events( + a2a_client: httpx.AsyncClient, +) -> None: + events = await _stream_rpc( + a2a_client, + "SendStreamingMessage", + _send_request(STREAMING_TRIGGER_TEXT), + request_id="stream-1", + ) + + assert [event["id"] for event in events] == ["stream-1"] * len(events) + + # First event is the newly created task in the submitted state. + task = events[0]["result"]["task"] + task_id = task["id"] + assert task["status"]["state"] == "TASK_STATE_SUBMITTED" + + # The queued informative update surfaces as a "working" status update + # carrying the informative text as an agent message, before any content. + working_update = events[1]["result"]["statusUpdate"] + assert working_update["taskId"] == task_id + assert working_update["status"]["state"] == "TASK_STATE_WORKING" + assert working_update["status"]["message"]["parts"][0]["text"] == "Thinking..." + + # The queued text chunks are combined into a single artifact update. + artifact_update = events[2]["result"]["artifactUpdate"] + assert artifact_update["taskId"] == task_id + assert artifact_update["artifact"]["parts"][0]["text"] == ( + f"Echo: {STREAMING_TRIGGER_TEXT}" + ) + + # The stream ends with a "completed" status update once end_stream() + # finishes and the end-of-conversation activity is processed. + final_update = events[-1]["result"]["statusUpdate"] + assert final_update["taskId"] == task_id + assert final_update["status"]["state"] == "TASK_STATE_COMPLETED" + + # The persisted task reflects the same terminal state via a plain GetTask. + get_response = await _post_rpc( + a2a_client, + "GetTask", + GetTaskRequest(id=task_id), + request_id="get-task", + ) + assert get_response["result"]["status"]["state"] == "TASK_STATE_COMPLETED" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jsonrpc_push_notification_config_is_explicitly_unsupported( + a2a_client: httpx.AsyncClient, +) -> None: + """The adapter's agent card does not advertise push-notification + capability, so every push-notification method must fail with the + protocol-defined PUSH_NOTIFICATION_NOT_SUPPORTED error rather than a + generic failure or a silent no-op.""" + + response = await _post_rpc( + a2a_client, + "CreateTaskPushNotificationConfig", + {"taskId": "missing-task", "url": "https://example.com/webhook"}, + request_id="push-create", + ) + + assert response["error"]["code"] == -32003 + assert response["error"]["message"] == ( + "Push notifications are not supported by the agent" + ) + error_details = response["error"]["data"][0] + assert error_details["reason"] == "PUSH_NOTIFICATION_NOT_SUPPORTED" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_rest_push_notification_config_is_explicitly_unsupported( + a2a_client: httpx.AsyncClient, +) -> None: + response = await a2a_client.post( + "/rest/tasks/missing-task/pushNotificationConfigs", + json={"url": "https://example.com/webhook"}, + headers={"A2A-Version": "1.0"}, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["error"]["status"] == "FAILED_PRECONDITION" + assert payload["error"]["details"][0]["reason"] == ( + "PUSH_NOTIFICATION_NOT_SUPPORTED" + ) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jsonrpc_extended_agent_card_is_explicitly_unconfigured( + a2a_client: httpx.AsyncClient, +) -> None: + """The basic agent card advertises `capabilities.extended_agent_card`, + but the adapter never configures an actual extended card or modifier on + the request handler, so retrieval must fail with the protocol-defined + EXTENDED_AGENT_CARD_NOT_CONFIGURED error.""" + + response = await _post_rpc( + a2a_client, + "GetExtendedAgentCard", + GetExtendedAgentCardRequest(), + request_id="extended-card", + ) + + assert response["error"]["code"] == -32007 + error_details = response["error"]["data"][0] + assert error_details["reason"] == "EXTENDED_AGENT_CARD_NOT_CONFIGURED" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_rest_extended_agent_card_is_explicitly_unconfigured( + a2a_client: httpx.AsyncClient, +) -> None: + response = await a2a_client.get( + "/rest/extendedAgentCard", + headers={"A2A-Version": "1.0"}, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["error"]["status"] == "FAILED_PRECONDITION" + assert payload["error"]["details"][0]["reason"] == ( + "EXTENDED_AGENT_CARD_NOT_CONFIGURED" + ) diff --git a/tests/hosting_a2a/test_a2a_adapter.py b/tests/hosting_a2a/test_a2a_adapter.py index 9df4e7f9b..72aa38019 100644 --- a/tests/hosting_a2a/test_a2a_adapter.py +++ b/tests/hosting_a2a/test_a2a_adapter.py @@ -60,11 +60,7 @@ def _request_context(identity=None, *, include_message=True): call_context = ServerCallContext( state={} if identity is None else {_CLAIMS_IDENTITY_KEY: identity} ) - request = ( - SendMessageRequest(message=_message()) - if include_message - else None - ) + request = SendMessageRequest(message=_message()) if include_message else None return RequestContext( call_context, request=request, @@ -430,10 +426,7 @@ async def test_get_agent_card_projects_interfaces_and_skills(): assert card.description == "Description" assert card.version == "1.2.3" assert card.supported_interfaces[0].url == "https://example.com/a2a" - assert ( - card.supported_interfaces[0].protocol_binding - == TransportProtocol.HTTP_JSON - ) + assert card.supported_interfaces[0].protocol_binding == TransportProtocol.HTTP_JSON assert card.supported_interfaces[0].protocol_version == "1.0" assert card.skills[0].id == "weather" assert card.skills[0].examples == ["Weather in Seattle"] diff --git a/tests/hosting_a2a/test_a2a_agent_extension.py b/tests/hosting_a2a/test_a2a_agent_extension.py index ce7b824c1..987c6d6b9 100644 --- a/tests/hosting_a2a/test_a2a_agent_extension.py +++ b/tests/hosting_a2a/test_a2a_agent_extension.py @@ -33,4 +33,3 @@ def test_message_registers_wrapped_handler_with_core_application(): wrap_handler.assert_called_once_with(handler, app) core_decorator.assert_called_once_with(wrapped_handler) assert result == "registered-handler" - diff --git a/tests/hosting_a2a/test_blob_task_store.py b/tests/hosting_a2a/test_blob_task_store.py index bee783f78..081258c45 100644 --- a/tests/hosting_a2a/test_blob_task_store.py +++ b/tests/hosting_a2a/test_blob_task_store.py @@ -33,9 +33,7 @@ async def __anext__(self): class _BlobPage: def __init__(self, blob_names, continuation_token=None): - self._blobs = [ - SimpleNamespace(name=blob_name) for blob_name in blob_names - ] + self._blobs = [SimpleNamespace(name=blob_name) for blob_name in blob_names] self.continuation_token = continuation_token def __aiter__(self): @@ -89,9 +87,7 @@ def test_constructor_creates_container_client_from_connection_string(): container_name="tasks", ) - from_connection_string.assert_called_once_with( - "UseDevelopmentStorage=true" - ) + from_connection_string.assert_called_once_with("UseDevelopmentStorage=true") blob_service_client.get_container_client.assert_called_once_with("tasks") assert store._container_client is container_client @@ -180,7 +176,9 @@ async def test_save_initializes_container_before_upload(): async def test_get_deserializes_protobuf_task(): store, container_client = _store() task = _task() - downloader = SimpleNamespace(readall=AsyncMock(return_value=task.SerializeToString())) + downloader = SimpleNamespace( + readall=AsyncMock(return_value=task.SerializeToString()) + ) container_client.download_blob = AsyncMock(return_value=downloader) result = await store.get(task.id, context=MagicMock()) @@ -197,7 +195,9 @@ async def test_get_deserializes_protobuf_task(): async def test_download_task_blob_uses_existing_blob_name(): store, container_client = _store() task = _task() - downloader = SimpleNamespace(readall=AsyncMock(return_value=task.SerializeToString())) + downloader = SimpleNamespace( + readall=AsyncMock(return_value=task.SerializeToString()) + ) container_client.download_blob = AsyncMock(return_value=downloader) result = await store._download_task_blob("TODOtask%2F1") From 22dd53f569326f42067eabfd255c57f21e5b544b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 14:09:39 -0700 Subject: [PATCH 26/39] Improving test coverage --- .../microsoft_agents/hosting/a2a/add_a2a.py | 4 + .../hosting/a2a/server/routes.py | 6 +- .../hosting_a2a/activity/test_a2a_activity.py | 39 +- tests/hosting_a2a/activity/test_utils.py | 16 - .../request_handling/test_request_handling.py | 483 +++++++++++++----- tests/hosting_a2a/server/test_routes.py | 156 ++++-- .../test_sdk_server_call_context_builder.py | 18 +- tests/hosting_a2a/test_a2a_adapter.py | 251 ++++----- tests/hosting_a2a/test_a2a_agent_extension.py | 89 +++- tests/hosting_a2a/test_a2a_turn_context.py | 5 +- tests/hosting_a2a/test_add_a2a.py | 144 ++++-- tests/hosting_a2a/test_blob_task_store.py | 463 +++++++---------- 12 files changed, 936 insertions(+), 738 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index 3c1c2ab59..cb6e9afbe 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -78,6 +78,10 @@ def add_a2a( rest_routes: list[BaseRoute] = [] interfaces = adapter.agent_interfaces + if not interfaces: + raise ValueError( + "No agent interfaces found. Cannot add A2A routes to application." + ) for interface in interfaces: if interface.protocol_binding == TransportProtocol.JSONRPC: diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 4822bf944..7b8c0661d 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -17,7 +17,10 @@ from starlette.routing import BaseRoute, Mount, Route from microsoft_agents.hosting.core import HttpRequestProtocol -from microsoft_agents.hosting.fastapi import JwtAuthorizationMiddleware +from microsoft_agents.hosting.fastapi import ( + JwtAuthorizationMiddleware, + jwt_authorization_decorator, +) from microsoft_agents.hosting.fastapi._fastapi_request_adapter import ( FastApiRequestAdapter, ) @@ -106,6 +109,7 @@ def wrap(route: BaseRoute) -> None: for child in route.routes: wrap(child) elif isinstance(route, Route) and id(route) not in wrapped: + route.endpoint = jwt_authorization_decorator(route.endpoint) route.app = JwtAuthorizationMiddleware(route.app) wrapped.add(id(route)) diff --git a/tests/hosting_a2a/activity/test_a2a_activity.py b/tests/hosting_a2a/activity/test_a2a_activity.py index 356aed215..72f5cc38c 100644 --- a/tests/hosting_a2a/activity/test_a2a_activity.py +++ b/tests/hosting_a2a/activity/test_a2a_activity.py @@ -59,30 +59,27 @@ def test_from_message_uses_explicit_task_id_and_populates_missing_context_id(): assert message.context_id -def test_create_activity_maps_all_supported_part_types(): - activity = A2AActivity._create_activity( - "conversation-1", - [ - Part(text="hello "), - Part(text="world"), - Part( - url="https://example.com/image.png", - media_type="image/png", - filename="image.png", - ), - Part( - raw=b"file contents", - media_type="application/octet-stream", - filename="data.bin", - ), - Part(data=ParseDict({"answer": 42}, Value())), - ], - is_ingress=True, - is_streaming=False, +def test_from_message_maps_all_supported_part_types(): + message = _message( + Part(text="hello "), + Part(text="world"), + Part( + url="https://example.com/image.png", + media_type="image/png", + filename="image.png", + ), + Part( + raw=b"file contents", + media_type="application/octet-stream", + filename="data.bin", + ), + Part(data=ParseDict({"answer": 42}, Value())), ) + activity = A2AActivity.from_message("request-1", None, message) + assert activity.text == "hello world" - assert activity.delivery_mode == DeliveryModes.expect_replies + assert activity.delivery_mode == DeliveryModes.stream assert len(activity.attachments) == 3 url_attachment, raw_attachment, data_attachment = activity.attachments diff --git a/tests/hosting_a2a/activity/test_utils.py b/tests/hosting_a2a/activity/test_utils.py index bd0637d9a..4e4ba7ad5 100644 --- a/tests/hosting_a2a/activity/test_utils.py +++ b/tests/hosting_a2a/activity/test_utils.py @@ -121,22 +121,6 @@ def test_create_artifact_from_data_rejects_none(): utils.create_artifact_from_data(None) -def test_get_a2a_metadata_rejects_none(): - with pytest.raises(ValueError, match="Data cannot be None"): - utils._get_a2a_metadata(None) - - -def test_get_a2a_metadata_reuses_cached_schema_for_same_type(): - utils._try_get_json_schema.cache_clear() - - first = utils._get_a2a_metadata(Entity(type="citation")) - second = utils._get_a2a_metadata(Entity(type="mention")) - - assert first["schema"] == second["schema"] - assert utils._try_get_json_schema.cache_info().misses == 1 - assert utils._try_get_json_schema.cache_info().hits == 1 - - def test_create_message_without_activity_has_agent_role_and_no_parts(): message = utils.create_message("context-1", "task-1", None) diff --git a/tests/hosting_a2a/request_handling/test_request_handling.py b/tests/hosting_a2a/request_handling/test_request_handling.py index 023a1e684..34feb48bb 100644 --- a/tests/hosting_a2a/request_handling/test_request_handling.py +++ b/tests/hosting_a2a/request_handling/test_request_handling.py @@ -1,20 +1,33 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +import asyncio import pytest -from a2a.server.events import EventQueue -from a2a.server.tasks import InMemoryTaskStore +from a2a.server.agent_execution import RequestContext +from a2a.server.context import ServerCallContext +from a2a.server.events import EventQueueLegacy +from a2a.server.tasks import InMemoryTaskStore, TaskUpdater from a2a.types import ( AgentCapabilities, AgentCard, AgentInterface, + CancelTaskRequest, + GetExtendedAgentCardRequest, + GetTaskRequest, + ListTasksRequest, Message, + Part, Role, + SendMessageRequest, Task, TaskState, + TaskStatus, + TaskStatusUpdateEvent, +) +from a2a.utils.errors import ( + ExtendedAgentCardNotConfiguredError, + UnsupportedOperationError, ) from microsoft_agents.hosting.a2a.request_handling import ( @@ -22,6 +35,10 @@ A2ARequestHandler, ) +pytestmark = pytest.mark.filterwarnings( + "ignore:label\\(\\) is deprecated\\. Use is_required\\(\\) or is_repeated\\(\\) instead\\.:DeprecationWarning" +) + def _agent_card(name: str = "Test agent") -> AgentCard: return AgentCard( @@ -38,162 +55,388 @@ def _agent_card(name: str = "Test agent") -> AgentCard: ) -@pytest.mark.asyncio -async def test_executor_delegates_message_to_adapter(): - adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) - executor = A2AAgentExecutor(adapter) - context = SimpleNamespace( - message=Message(message_id="message-1", role=Role.ROLE_USER), - current_task=object(), +def _message( + text: str, + *, + message_id: str, + role: Role = Role.ROLE_USER, + task_id: str = "", + context_id: str = "", +) -> Message: + return Message( + message_id=message_id, + role=role, + task_id=task_id, + context_id=context_id, + parts=[Part(text=text)], ) - event_queue = AsyncMock(spec=EventQueue) - await executor.execute(context, event_queue) - adapter.execute_agent_turn.assert_awaited_once_with(context, event_queue) +def _send_request( + text: str, + *, + message_id: str, + task_id: str = "", + context_id: str = "", +) -> SendMessageRequest: + return SendMessageRequest( + message=_message( + text, + message_id=message_id, + task_id=task_id, + context_id=context_id, + ) + ) + + +def _history_text(task: Task) -> list[str]: + return [message.parts[0].text for message in task.history] + + +def _status_text(task: Task) -> str | None: + if not task.status.HasField("message"): + return None + return task.status.message.parts[0].text + + +async def _wait_for_task_state(task_store, task_id, call_context, expected_state): + async def wait(): + while True: + task = await task_store.get(task_id, call_context) + if task is not None and task.status.state == expected_state: + return task + await asyncio.sleep(0) + + return await asyncio.wait_for(wait(), timeout=1) + + +class _CompletingAdapter: + def __init__(self) -> None: + self.contexts: list[RequestContext] = [] + + async def execute_agent_turn(self, context, event_queue) -> None: + self.contexts.append(context) + updater = TaskUpdater( + event_queue=event_queue, + task_id=context.task_id, + context_id=context.context_id, + ) + await updater.complete( + _message( + "completed", + message_id="response-1", + role=Role.ROLE_AGENT, + task_id=context.task_id, + context_id=context.context_id, + ) + ) + + async def cancel_agent_turn(self, context, event_queue) -> None: + raise AssertionError("Completed tasks should not be canceled") + + +class _InputThenCompleteAdapter: + def __init__(self) -> None: + self.contexts: list[RequestContext] = [] + self.current_task_states: list[int | None] = [] + + async def execute_agent_turn(self, context, event_queue) -> None: + self.contexts.append(context) + self.current_task_states.append( + context.current_task.status.state if context.current_task else None + ) + updater = TaskUpdater( + event_queue=event_queue, + task_id=context.task_id, + context_id=context.context_id, + ) + + if len(self.contexts) == 1: + await updater.update_status( + TaskState.TASK_STATE_INPUT_REQUIRED, + _message( + "More information required", + message_id="prompt-1", + role=Role.ROLE_AGENT, + task_id=context.task_id, + context_id=context.context_id, + ), + ) + return + + await updater.complete( + _message( + "Request completed", + message_id="response-2", + role=Role.ROLE_AGENT, + task_id=context.task_id, + context_id=context.context_id, + ) + ) + + async def cancel_agent_turn(self, context, event_queue) -> None: + raise AssertionError("This task should not be canceled") + + +class _CancelableAdapter: + def __init__(self) -> None: + self.started = asyncio.Event() + self.cancel_context: RequestContext | None = None + + async def execute_agent_turn(self, context, event_queue) -> None: + updater = TaskUpdater( + event_queue=event_queue, + task_id=context.task_id, + context_id=context.context_id, + ) + await updater.start_work() + self.started.set() + await asyncio.Event().wait() + + async def cancel_agent_turn(self, context, event_queue) -> None: + self.cancel_context = context + + +class _FailingAdapter: + async def execute_agent_turn(self, context, event_queue) -> None: + raise RuntimeError("agent failed") + + async def cancel_agent_turn(self, context, event_queue) -> None: + raise AssertionError("Failed tasks should not be canceled") @pytest.mark.asyncio -async def test_executor_submits_new_task_before_delegating(): - adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) +async def test_executor_establishes_task_mode_before_adapter_events(): + adapter = _CompletingAdapter() executor = A2AAgentExecutor(adapter) - context = SimpleNamespace( - message=Message(message_id="message-1", role=Role.ROLE_USER), - current_task=None, + request = _send_request("hello", message_id="message-1") + context = RequestContext( + ServerCallContext(), + request=request, task_id="task-1", context_id="context-1", ) - event_queue = AsyncMock(spec=EventQueue) + event_queue = EventQueueLegacy() await executor.execute(context, event_queue) - task = event_queue.enqueue_event.call_args.args[0] - assert isinstance(task, Task) - assert task.id == "task-1" - assert task.context_id == "context-1" - assert task.status.state == TaskState.TASK_STATE_SUBMITTED - assert task.status.HasField("timestamp") - assert list(task.history) == [context.message] - adapter.execute_agent_turn.assert_awaited_once_with(context, event_queue) + initial_task = await event_queue.dequeue_event() + completion = await event_queue.dequeue_event() + assert isinstance(initial_task, Task) + assert initial_task.id == "task-1" + assert initial_task.context_id == "context-1" + assert initial_task.status.state == TaskState.TASK_STATE_SUBMITTED + assert initial_task.status.HasField("timestamp") + assert list(initial_task.history) == [request.message] -@pytest.mark.asyncio -async def test_executor_drops_context_without_message(caplog): - adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) - executor = A2AAgentExecutor(adapter) - context = SimpleNamespace(message=None) - event_queue = AsyncMock(spec=EventQueue) - - with caplog.at_level("WARNING"): - await executor.execute(context, event_queue) - - adapter.execute_agent_turn.assert_not_awaited() - assert "No message found in the request context" in caplog.text + assert isinstance(completion, TaskStatusUpdateEvent) + assert completion.task_id == initial_task.id + assert completion.context_id == initial_task.context_id + assert completion.status.state == TaskState.TASK_STATE_COMPLETED @pytest.mark.asyncio -async def test_executor_cancel_updates_task_and_notifies_adapter(): - adapter = SimpleNamespace( - execute_agent_turn=AsyncMock(), - cancel_agent_turn=AsyncMock(), - ) +async def test_executor_continuation_updates_existing_task_without_replacing_it(): + adapter = _CompletingAdapter() executor = A2AAgentExecutor(adapter) - context = SimpleNamespace(task_id="task-1", context_id="context-1") - event_queue = AsyncMock(spec=EventQueue) - updater = AsyncMock() - - with patch( - "microsoft_agents.hosting.a2a.request_handling." - "a2a_agent_executor.TaskUpdater", - return_value=updater, - ) as task_updater: - await executor.cancel(context, event_queue) - - task_updater.assert_called_once_with( - event_queue=event_queue, - task_id="task-1", + existing_task = Task( + id="task-1", context_id="context-1", + status=TaskStatus(state=TaskState.TASK_STATE_INPUT_REQUIRED), + ) + context = RequestContext( + ServerCallContext(), + request=_send_request( + "additional input", + message_id="message-2", + task_id=existing_task.id, + context_id=existing_task.context_id, + ), + task_id=existing_task.id, + context_id=existing_task.context_id, + task=existing_task, ) - updater.cancel.assert_awaited_once() - adapter.cancel_agent_turn.assert_awaited_once_with(context, event_queue) + event_queue = EventQueueLegacy() + + await executor.execute(context, event_queue) + + event = await event_queue.dequeue_event() + assert isinstance(event, TaskStatusUpdateEvent) + assert event.task_id == existing_task.id + assert event.status.state == TaskState.TASK_STATE_COMPLETED + assert adapter.contexts[0].current_task is existing_task @pytest.mark.asyncio -async def test_executor_cancel_uses_empty_ids_when_context_ids_are_missing(): - adapter = SimpleNamespace(cancel_agent_turn=AsyncMock()) - executor = A2AAgentExecutor(adapter) - context = SimpleNamespace(task_id=None, context_id=None) - event_queue = AsyncMock(spec=EventQueue) - updater = AsyncMock() - - with patch( - "microsoft_agents.hosting.a2a.request_handling." - "a2a_agent_executor.TaskUpdater", - return_value=updater, - ) as task_updater: - await executor.cancel(context, event_queue) - - task_updater.assert_called_once_with( - event_queue=event_queue, - task_id="", - context_id="", - ) - adapter.cancel_agent_turn.assert_awaited_once_with(context, event_queue) +async def test_request_handler_completes_persists_and_queries_task(): + adapter = _CompletingAdapter() + task_store = InMemoryTaskStore() + handler = A2ARequestHandler(adapter, task_store, _agent_card()) + call_context = ServerCallContext() + request = _send_request("hello", message_id="message-1") + + try: + result = await handler.on_message_send(request, call_context) + + assert isinstance(result, Task) + assert result.status.state == TaskState.TASK_STATE_COMPLETED + assert request.message.task_id == result.id + assert request.message.context_id == result.context_id + assert _history_text(result) == ["hello"] + assert _status_text(result) == "completed" + assert adapter.contexts[0].current_task is None + + stored = await task_store.get(result.id, call_context) + assert stored == result + + without_history = await handler.on_get_task( + GetTaskRequest(id=result.id, history_length=0), + call_context, + ) + assert _history_text(without_history) == [] + assert _status_text(without_history) == "completed" + assert _history_text(stored) == ["hello"] + + listed = await handler.on_list_tasks( + ListTasksRequest(context_id=result.context_id), + call_context, + ) + assert [task.id for task in listed.tasks] == [result.id] + assert _status_text(listed.tasks[0]) == "completed" + finally: + await handler.aclose() @pytest.mark.asyncio -async def test_executor_cancel_does_not_notify_adapter_when_status_update_fails(): - adapter = SimpleNamespace(cancel_agent_turn=AsyncMock()) - executor = A2AAgentExecutor(adapter) - context = SimpleNamespace(task_id="task-1", context_id="context-1") - event_queue = AsyncMock(spec=EventQueue) - updater = AsyncMock() - updater.cancel.side_effect = RuntimeError("queue failed") +async def test_request_handler_continues_interrupted_task_in_same_a2a_context(): + adapter = _InputThenCompleteAdapter() + task_store = InMemoryTaskStore() + handler = A2ARequestHandler(adapter, task_store, _agent_card()) + call_context = ServerCallContext() + + try: + first_result = await handler.on_message_send( + _send_request("initial request", message_id="message-1"), + call_context, + ) + + assert isinstance(first_result, Task) + assert first_result.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + assert _history_text(first_result) == ["initial request"] + assert _status_text(first_result) == "More information required" + + second_result = await handler.on_message_send( + _send_request( + "additional input", + message_id="message-2", + task_id=first_result.id, + context_id=first_result.context_id, + ), + call_context, + ) + + assert isinstance(second_result, Task) + assert second_result.id == first_result.id + assert second_result.context_id == first_result.context_id + assert second_result.status.state == TaskState.TASK_STATE_COMPLETED + assert _history_text(second_result) == [ + "initial request", + "More information required", + "additional input", + ] + assert _status_text(second_result) == "Request completed" + assert adapter.current_task_states[0] is None + assert adapter.current_task_states[1] == TaskState.TASK_STATE_INPUT_REQUIRED + assert adapter.contexts[1].current_task.id == first_result.id + finally: + await handler.aclose() - with patch( - "microsoft_agents.hosting.a2a.request_handling." - "a2a_agent_executor.TaskUpdater", - return_value=updater, - ): - with pytest.raises(RuntimeError, match="queue failed"): - await executor.cancel(context, event_queue) - adapter.cancel_agent_turn.assert_not_awaited() +@pytest.mark.asyncio +async def test_request_handler_cancels_and_persists_active_task(): + adapter = _CancelableAdapter() + task_store = InMemoryTaskStore() + handler = A2ARequestHandler(adapter, task_store, _agent_card()) + call_context = ServerCallContext() + request = _send_request("long-running request", message_id="message-1") + send_call = asyncio.create_task(handler.on_message_send(request, call_context)) + + try: + await asyncio.wait_for(adapter.started.wait(), timeout=1) + active_task = await _wait_for_task_state( + task_store, + request.message.task_id, + call_context, + TaskState.TASK_STATE_WORKING, + ) + canceled = await asyncio.wait_for( + handler.on_cancel_task( + CancelTaskRequest(id=request.message.task_id), + call_context, + ), + timeout=1, + ) + send_result = await asyncio.wait_for(send_call, timeout=1) + + assert canceled.status.state == TaskState.TASK_STATE_CANCELED + assert send_result == canceled + assert adapter.cancel_context is not None + assert adapter.cancel_context.task_id == canceled.id + assert adapter.cancel_context.context_id == canceled.context_id + assert active_task.status.state == TaskState.TASK_STATE_WORKING + assert adapter.cancel_context.current_task.id == active_task.id + + stored = await task_store.get(canceled.id, call_context) + assert stored == canceled + finally: + if not send_call.done(): + send_call.cancel() + await handler.aclose() @pytest.mark.asyncio -async def test_executor_cancel_propagates_adapter_failure(): - adapter = SimpleNamespace( - cancel_agent_turn=AsyncMock(side_effect=RuntimeError("agent failed")) - ) - executor = A2AAgentExecutor(adapter) - context = SimpleNamespace(task_id="task-1", context_id="context-1") - event_queue = AsyncMock(spec=EventQueue) - updater = AsyncMock() - - with patch( - "microsoft_agents.hosting.a2a.request_handling." - "a2a_agent_executor.TaskUpdater", - return_value=updater, - ): +async def test_request_handler_persists_failed_task_when_adapter_raises(): + task_store = InMemoryTaskStore() + handler = A2ARequestHandler(_FailingAdapter(), task_store, _agent_card()) + call_context = ServerCallContext() + request = _send_request("fail", message_id="message-1") + + try: with pytest.raises(RuntimeError, match="agent failed"): - await executor.cancel(context, event_queue) + await handler.on_message_send(request, call_context) - updater.cancel.assert_awaited_once() + stored = await task_store.get(request.message.task_id, call_context) + assert stored is not None + assert stored.status.state == TaskState.TASK_STATE_FAILED + finally: + await handler.aclose() -def test_request_handler_uses_adapter_and_updates_agent_card(): - adapter = SimpleNamespace(execute_agent_turn=AsyncMock()) +@pytest.mark.asyncio +async def test_request_handler_update_agent_card_changes_extended_card_capability(): original_card = _agent_card() updated_card = _agent_card("Updated agent") - + updated_card.capabilities.extended_agent_card = True handler = A2ARequestHandler( - adapter, + _CompletingAdapter(), InMemoryTaskStore(), original_card, ) - handler.update_agent_card(updated_card) - assert handler._adapter is adapter - assert handler.agent_executor._adapter is adapter - assert handler._agent_card is updated_card + try: + with pytest.raises(UnsupportedOperationError): + await handler.on_get_extended_agent_card( + GetExtendedAgentCardRequest(), + ServerCallContext(), + ) + + handler.update_agent_card(updated_card) + + with pytest.raises(ExtendedAgentCardNotConfiguredError): + await handler.on_get_extended_agent_card( + GetExtendedAgentCardRequest(), + ServerCallContext(), + ) + finally: + await handler.aclose() diff --git a/tests/hosting_a2a/server/test_routes.py b/tests/hosting_a2a/server/test_routes.py index e5613b2dc..57889dac0 100644 --- a/tests/hosting_a2a/server/test_routes.py +++ b/tests/hosting_a2a/server/test_routes.py @@ -3,71 +3,102 @@ from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest +from a2a.server.request_handlers import RequestHandler +from a2a.types import AgentCapabilities, AgentCard, ListTasksResponse +from starlette.applications import Starlette from starlette.requests import Request +from starlette.responses import JSONResponse from starlette.routing import Mount, Route -from microsoft_agents.hosting.fastapi import JwtAuthorizationMiddleware +from microsoft_agents.hosting.core import AgentAuthConfiguration, ClaimsIdentity from microsoft_agents.hosting.a2a.server import routes -from microsoft_agents.hosting.a2a.server.sdk_server_call_context_builder import ( - SDKServerCallContextBuilder, -) +from microsoft_agents.hosting.a2a.server._constants import _CLAIMS_IDENTITY_KEY -def test_create_jsonrpc_routes_supplies_sdk_context_builder(): - request_handler = MagicMock() - expected_routes = [MagicMock(spec=Route)] +async def _request_with_identity(app, identity, method, path, **kwargs): + async def authenticated_app(scope, receive, send): + scope.setdefault("state", {})["claims_identity"] = identity + await app(scope, receive, send) - with patch.object( - routes, - "_create_jsonrpc_routes", - return_value=expected_routes, - ) as create_routes: - result = routes.create_jsonrpc_routes( - request_handler, - "/a2a", - enable_v0_3_compat=True, - ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=authenticated_app), + base_url="http://testserver", + ) as client: + return await client.request(method, path, **kwargs) - assert result is expected_routes - create_routes.assert_called_once() - assert create_routes.call_args.args == (request_handler, "/a2a") - assert isinstance( - create_routes.call_args.kwargs["context_builder"], - SDKServerCallContextBuilder, + +@pytest.mark.asyncio +async def test_jsonrpc_routes_forward_authenticated_identity_to_request_handler(): + request_handler = MagicMock(spec=RequestHandler) + request_handler.on_list_tasks.return_value = ListTasksResponse(tasks=[]) + identity = ClaimsIdentity({"sub": "agent-1"}) + app = Starlette(routes=routes.create_jsonrpc_routes(request_handler, "/a2a")) + + response = await _request_with_identity( + app, + identity, + "POST", + "/a2a", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "request-1", + "method": "ListTasks", + "params": {}, + }, ) - assert create_routes.call_args.kwargs["enable_v0_3_compat"] is True + assert response.status_code == 200 + assert response.json()["result"]["tasks"] == [] + context = request_handler.on_list_tasks.call_args.args[1] + assert context.state[_CLAIMS_IDENTITY_KEY] is identity + assert context.state["headers"]["a2a-version"] == "1.0" -def test_create_rest_routes_supplies_sdk_context_builder(): - request_handler = MagicMock() - expected_routes = [MagicMock(spec=Route)] - with patch.object( - routes, - "_create_rest_routes", - return_value=expected_routes, - ) as create_routes: - result = routes.create_rest_routes( +@pytest.mark.asyncio +async def test_rest_routes_forward_authenticated_identity_to_request_handler(): + request_handler = MagicMock(spec=RequestHandler) + request_handler.on_list_tasks.return_value = ListTasksResponse(tasks=[]) + identity = ClaimsIdentity({"sub": "agent-1"}) + app = Starlette( + routes=routes.create_rest_routes( request_handler, path_prefix="/a2a", - enable_v0_3_compat=True, ) + ) - assert result is expected_routes - create_routes.assert_called_once() - assert create_routes.call_args.args == (request_handler,) - assert isinstance( - create_routes.call_args.kwargs["context_builder"], - SDKServerCallContextBuilder, + response = await _request_with_identity( + app, + identity, + "GET", + "/a2a/tasks", + headers={"A2A-Version": "1.0"}, ) - assert create_routes.call_args.kwargs["path_prefix"] == "/a2a" - assert create_routes.call_args.kwargs["enable_v0_3_compat"] is True + + assert response.status_code == 200 + assert response.json()["tasks"] == [] + context = request_handler.on_list_tasks.call_args.args[1] + assert context.state[_CLAIMS_IDENTITY_KEY] is identity + assert context.state["headers"]["a2a-version"] == "1.0" @pytest.mark.asyncio async def test_create_agent_card_routes_adapts_request_and_uses_interface_prefix(): - get_agent_card = AsyncMock(return_value=MagicMock()) + observed = {} + + async def get_agent_card(request, prefix): + observed["request"] = request + observed["prefix"] = prefix + return AgentCard( + name="Test agent", + description="Test description", + version="1.0.0", + supported_interfaces=[], + capabilities=AgentCapabilities(), + ) + route = routes.create_agent_card_routes( get_agent_card, "/a2a/.well-known/agent-card.json", @@ -86,25 +117,42 @@ async def test_create_agent_card_routes_adapts_request_and_uses_interface_prefix } ) - with patch.object(routes, "agent_card_to_dict", return_value={"name": "agent"}): - response = await route.endpoint(request) + response = await route.endpoint(request) assert route.path == "/a2a/.well-known/agent-card.json" assert response.status_code == 200 - assert response.body == b'{"name":"agent"}' - get_agent_card.assert_awaited_once() - assert get_agent_card.call_args.args[1] == "/a2a" + assert response.body == ( + b'{"name":"Test agent","description":"Test description",' + b'"version":"1.0.0","capabilities":{}}' + ) + assert ( + observed["request"].url == "https://example.com/a2a/.well-known/agent-card.json" + ) + assert observed["prefix"] == "/a2a" -def test_use_jwt_middleware_wraps_shared_mounted_route_once(): +@pytest.mark.asyncio +async def test_use_jwt_middleware_authorizes_shared_mounted_route_once(): async def endpoint(request): - return None + return JSONResponse({"ok": True}) route = Route("/messages", endpoint=endpoint) - original_app = route.app mounted = Mount("/tenant", routes=[route]) - routes.use_jwt_middleware([route, mounted]) + app = Starlette(routes=[route]) + app.state.agent_configuration = AgentAuthConfiguration() + + with patch( + "microsoft_agents.hosting.fastapi.jwt_authorization_middleware." + "_authorize_request", + new=AsyncMock(return_value=ClaimsIdentity({"sub": "agent-1"})), + ) as authorize: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get("/messages") - assert isinstance(route.app, JwtAuthorizationMiddleware) - assert route.app.app is original_app + assert response.status_code == 200 + assert response.json() == {"ok": True} + authorize.assert_awaited_once() diff --git a/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py b/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py index 21302bae8..96d96f6ab 100644 --- a/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py +++ b/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from unittest.mock import MagicMock, patch - from a2a.auth.user import UnauthenticatedUser from starlette.requests import Request @@ -21,7 +19,7 @@ def _request(*, claims_identity=None): "raw_path": b"/a2a", "query_string": b"", "headers": [ - (b"x-a2a-extensions", b"extension-1"), + (b"a2a-extensions", b"extension-1"), (b"x-test", b"value"), ], "scheme": "https", @@ -38,28 +36,20 @@ def test_build_copies_headers_extensions_and_claims_identity(): identity = ClaimsIdentity({"sub": "agent-1"}, True) request = _request(claims_identity=identity) builder = SDKServerCallContextBuilder() - user = UnauthenticatedUser() - builder.build_user = MagicMock(return_value=user) - with patch( - "microsoft_agents.hosting.a2a.server." - "sdk_server_call_context_builder.get_requested_extensions", - return_value={"extension-1"}, - ) as get_extensions: - context = builder.build(request) + context = builder.build(request) - assert context.user is user + assert isinstance(context.user, UnauthenticatedUser) assert context.state["headers"]["x-test"] == "value" assert context.state[_CLAIMS_IDENTITY_KEY] is identity assert context.requested_extensions == {"extension-1"} - get_extensions.assert_called_once() def test_build_omits_claims_identity_when_middleware_did_not_set_it(): request = _request() builder = SDKServerCallContextBuilder() - builder.build_user = MagicMock(return_value=UnauthenticatedUser()) context = builder.build(request) + assert isinstance(context.user, UnauthenticatedUser) assert _CLAIMS_IDENTITY_KEY not in context.state diff --git a/tests/hosting_a2a/test_a2a_adapter.py b/tests/hosting_a2a/test_a2a_adapter.py index 72aa38019..1ef16e33c 100644 --- a/tests/hosting_a2a/test_a2a_adapter.py +++ b/tests/hosting_a2a/test_a2a_adapter.py @@ -1,13 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import asyncio from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest from a2a.server.agent_execution import RequestContext from a2a.server.context import ServerCallContext -from a2a.server.events import EventQueue +from a2a.server.events import EventQueue, EventQueueLegacy from a2a.server.tasks import InMemoryTaskStore, TaskStore from a2a.types import ( AgentInterface, @@ -85,13 +86,12 @@ def _turn_context(adapter, event_queue): def test_constructor_uses_defaults_and_creates_request_handler(): adapter = A2AAdapter(_agent()) - assert isinstance(adapter._task_store, InMemoryTaskStore) assert adapter.agent_interfaces == [] assert adapter.skills == [] assert adapter.a2a_request_handler is not None -def test_constructor_preserves_custom_interfaces_skills_and_task_store(): +def test_constructor_preserves_custom_interfaces_and_skills(): interface = AgentInterface( url="https://example.com/a2a", protocol_binding=TransportProtocol.HTTP_JSON, @@ -113,7 +113,6 @@ def test_constructor_preserves_custom_interfaces_skills_and_task_store(): assert adapter.agent_interfaces == [interface] assert adapter.skills == [skill] - assert adapter._task_store is task_store @pytest.mark.asyncio @@ -137,60 +136,76 @@ async def test_execute_agent_turn_rejects_invalid_identity(): @pytest.mark.asyncio -async def test_execute_agent_turn_converts_message_and_processes_activity(): +async def test_execute_agent_turn_converts_message_and_exposes_a2a_services(): identity = ClaimsIdentity({"sub": "agent-1"}) context = _request_context(identity) - event_queue = MagicMock(spec=EventQueue) - adapter = A2AAdapter(_agent()) - adapter._process_activity_with_a2a = AsyncMock() + event_queue = EventQueueLegacy() + task_store = InMemoryTaskStore() + observed = {} + + async def on_turn(turn_context): + observed["context"] = turn_context + + adapter = A2AAdapter( + SimpleNamespace(on_turn=on_turn), + task_store=task_store, + ) await adapter.execute_agent_turn(context, event_queue) - adapter._process_activity_with_a2a.assert_awaited_once() - actual_identity, activity, actual_context, actual_queue = ( - adapter._process_activity_with_a2a.call_args.args + turn_context = observed["context"] + assert turn_context.identity is identity + assert isinstance(turn_context.activity, A2AActivity) + assert turn_context.activity.text == "hello" + assert turn_context.activity.request_id + assert turn_context.services.get(RequestContext) is context + assert turn_context.services.get(EventQueue) is event_queue + assert turn_context.services.get(TaskStore) is task_store + assert ( + turn_context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] + == AuthenticationConstants.AGENTS_SDK_SCOPE ) - assert actual_identity is identity - assert isinstance(activity, A2AActivity) - assert activity.text == "hello" - assert activity.request_id - assert actual_context is context - assert actual_queue is event_queue @pytest.mark.asyncio -async def test_cancel_agent_turn_processes_user_cancelled_activity(): +async def test_cancel_agent_turn_delivers_user_cancelled_activity(): identity = ClaimsIdentity({"sub": "agent-1"}) context = _request_context(identity, include_message=False) - event_queue = MagicMock(spec=EventQueue) - adapter = A2AAdapter(_agent()) - adapter._process_activity_with_a2a = AsyncMock() + event_queue = EventQueueLegacy() + observed = {} + + async def on_turn(turn_context): + observed["context"] = turn_context + + adapter = A2AAdapter(SimpleNamespace(on_turn=on_turn)) await adapter.cancel_agent_turn(context, event_queue) - adapter._process_activity_with_a2a.assert_awaited_once() - actual_identity, activity, actual_context, actual_queue = ( - adapter._process_activity_with_a2a.call_args.args - ) - assert actual_identity is identity + turn_context = observed["context"] + activity = turn_context.activity + assert turn_context.identity is identity assert activity.type == ActivityTypes.end_of_conversation assert activity.code == EndOfConversationCodes.user_cancelled assert activity.channel_id == Channels.a2a assert activity.recipient.id == "assistant" assert activity.from_property.id == "unknown" - assert actual_context is context - assert actual_queue is event_queue + assert turn_context.services.get(RequestContext) is context + assert turn_context.services.get(EventQueue) is event_queue @pytest.mark.asyncio async def test_cancel_agent_turn_uses_anonymous_identity_by_default(): context = _request_context(include_message=False) - adapter = A2AAdapter(_agent()) - adapter._process_activity_with_a2a = AsyncMock() + observed = {} - await adapter.cancel_agent_turn(context, MagicMock(spec=EventQueue)) + async def on_turn(turn_context): + observed["identity"] = turn_context.identity - identity = adapter._process_activity_with_a2a.call_args.args[0] + adapter = A2AAdapter(SimpleNamespace(on_turn=on_turn)) + + await adapter.cancel_agent_turn(context, EventQueueLegacy()) + + identity = observed["identity"] assert isinstance(identity, ClaimsIdentity) assert identity.allow_anonymous is True @@ -199,46 +214,13 @@ async def test_cancel_agent_turn_uses_anonymous_identity_by_default(): async def test_cancel_agent_turn_rejects_invalid_identity(): context = _request_context(identity="invalid", include_message=False) adapter = A2AAdapter(_agent()) - adapter._process_activity_with_a2a = AsyncMock() with pytest.raises(RuntimeError, match="Invalid identity"): - await adapter.cancel_agent_turn(context, MagicMock(spec=EventQueue)) - - adapter._process_activity_with_a2a.assert_not_awaited() + await adapter.cancel_agent_turn(context, EventQueueLegacy()) @pytest.mark.asyncio -async def test_process_activity_registers_services_and_runs_agent_pipeline(): - agent = _agent() - adapter = A2AAdapter(agent) - adapter.run_pipeline = AsyncMock() - identity = ClaimsIdentity() - request_context = MagicMock(spec=RequestContext) - event_queue = MagicMock(spec=EventQueue) - activity = A2AActivity(type=ActivityTypes.message) - - await adapter._process_activity_with_a2a( - identity, - activity, - request_context, - event_queue, - ) - - adapter.run_pipeline.assert_awaited_once() - context, handler = adapter.run_pipeline.call_args.args - assert handler is agent.on_turn - assert context.identity is identity - assert ( - context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] - == AuthenticationConstants.AGENTS_SDK_SCOPE - ) - assert context.services.get(RequestContext) is request_context - assert context.services.get(EventQueue) is event_queue - assert context.services.get(TaskStore) is adapter._task_store - - -@pytest.mark.asyncio -async def test_process_activity_sets_agent_caller_and_audience(): +async def test_execute_agent_turn_sets_agent_caller_and_audience(): identity = ClaimsIdentity( { AuthenticationConstants.VERSION_CLAIM: "2.0", @@ -246,58 +228,52 @@ async def test_process_activity_sets_agent_caller_and_audience(): AuthenticationConstants.AUTHORIZED_PARTY: "calling-agent", } ) - adapter = A2AAdapter(_agent()) - adapter.run_pipeline = AsyncMock() - activity = A2AActivity(type=ActivityTypes.message) - - await adapter._process_activity_with_a2a( - identity, - activity, - MagicMock(spec=RequestContext), - MagicMock(spec=EventQueue), + observed = {} + + async def on_turn(turn_context): + observed["context"] = turn_context + + adapter = A2AAdapter(SimpleNamespace(on_turn=on_turn)) + + await adapter.execute_agent_turn( + _request_context(identity), + EventQueueLegacy(), ) - context = adapter.run_pipeline.call_args.args[0] + context = observed["context"] assert ( context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] == "app://calling-agent" ) - assert activity.caller_id == ( + assert context.activity.caller_id == ( f"{CallerIdConstants.agent_to_agent_prefix}calling-agent" ) @pytest.mark.asyncio -async def test_process_activity_rejects_non_a2a_channel(): - adapter = A2AAdapter(_agent()) - - with pytest.raises(ValueError, match="channel_id must be 'a2a'"): - await adapter._process_activity_with_a2a( - ClaimsIdentity(), - Activity(type=ActivityTypes.message, channel_id=Channels.webchat), - MagicMock(spec=RequestContext), - MagicMock(spec=EventQueue), - ) - - -@pytest.mark.asyncio -async def test_send_activities_dispatches_supported_activity_types(): +async def test_send_activities_emits_protocol_events_for_supported_activities(): adapter = A2AAdapter(_agent()) - adapter._on_streaming_response = AsyncMock() - adapter._on_message_response = AsyncMock() - adapter._on_end_of_conversation_response = AsyncMock() - context = MagicMock(spec=TurnContext) + event_queue = EventQueueLegacy() + context = _turn_context(adapter, event_queue) stream_info = StreamInfo( stream_id="stream-1", - stream_type="informative", + stream_type="content", stream_sequence=1, ) streaming = A2AActivity( type=ActivityTypes.message, + text="chunk", entities=[stream_info], ) - message = A2AActivity(type=ActivityTypes.message, text="hello") - completed = A2AActivity(type=ActivityTypes.end_of_conversation) + message = A2AActivity( + type=ActivityTypes.message, + text="working", + input_hint=InputHints.expecting_input, + ) + completed = A2AActivity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + ) ignored = Activity( type=ActivityTypes.message, channel_id=Channels.webchat, @@ -309,62 +285,24 @@ async def test_send_activities_dispatches_supported_activity_types(): ) assert result == [] - adapter._on_streaming_response.assert_awaited_once_with( - context, - streaming, - stream_info, - ) - adapter._on_message_response.assert_awaited_once_with(context, message) - adapter._on_end_of_conversation_response.assert_awaited_once_with( - context, - completed, - ) + artifact_event = await event_queue.dequeue_event() + message_event = await event_queue.dequeue_event() + completed_event = await event_queue.dequeue_event() + assert isinstance(artifact_event, TaskArtifactUpdateEvent) + assert artifact_event.task_id == "task-1" + assert artifact_event.context_id == "context-1" + assert artifact_event.artifact.artifact_id == "stream-1" + assert artifact_event.artifact.parts[0].text == "chunk" -@pytest.mark.asyncio -async def test_message_response_enqueues_status_update(): - event_queue = MagicMock(spec=EventQueue) - event_queue.enqueue_event = AsyncMock() - adapter = A2AAdapter(_agent()) - context = _turn_context(adapter, event_queue) - activity = A2AActivity( - type=ActivityTypes.message, - text="working", - input_hint=InputHints.expecting_input, - ) - - await adapter._on_message_response(context, activity) - - event = event_queue.enqueue_event.call_args.args[0] - assert isinstance(event, TaskStatusUpdateEvent) - assert event.task_id == "task-1" - assert event.context_id == "context-1" - assert event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED - assert event.status.message.parts[0].text == "working" - - -@pytest.mark.asyncio -async def test_streaming_response_enqueues_artifact_update(): - event_queue = MagicMock(spec=EventQueue) - event_queue.enqueue_event = AsyncMock() - adapter = A2AAdapter(_agent()) - context = _turn_context(adapter, event_queue) - activity = A2AActivity(type=ActivityTypes.message, text="chunk") - stream_info = StreamInfo( - stream_id="artifact-1", - stream_type="content", - stream_sequence=1, - ) - - await adapter._on_streaming_response(context, activity, stream_info) + assert isinstance(message_event, TaskStatusUpdateEvent) + assert message_event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + assert message_event.status.message.parts[0].text == "working" - event = event_queue.enqueue_event.call_args.args[0] - assert isinstance(event, TaskArtifactUpdateEvent) - assert event.task_id == "task-1" - assert event.context_id == "context-1" - assert event.artifact.artifact_id == "artifact-1" - assert event.artifact.parts[0].text == "chunk" - assert event.last_chunk is False + assert isinstance(completed_event, TaskStatusUpdateEvent) + assert completed_event.status.state == TaskState.TASK_STATE_COMPLETED + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(event_queue.dequeue_event(), timeout=0.05) @pytest.mark.asyncio @@ -377,8 +315,7 @@ async def test_streaming_response_enqueues_artifact_update(): ], ) async def test_end_of_conversation_maps_terminal_state(code, expected_state): - event_queue = MagicMock(spec=EventQueue) - event_queue.enqueue_event = AsyncMock() + event_queue = EventQueueLegacy() adapter = A2AAdapter(_agent()) context = _turn_context(adapter, event_queue) activity = A2AActivity( @@ -386,9 +323,9 @@ async def test_end_of_conversation_maps_terminal_state(code, expected_state): code=code, ) - await adapter._on_end_of_conversation_response(context, activity) + await adapter.send_activities(context, [activity]) - event = event_queue.enqueue_event.call_args.args[0] + event = await event_queue.dequeue_event() assert isinstance(event, TaskStatusUpdateEvent) assert event.status.state == expected_state diff --git a/tests/hosting_a2a/test_a2a_agent_extension.py b/tests/hosting_a2a/test_a2a_agent_extension.py index 987c6d6b9..4386a9857 100644 --- a/tests/hosting_a2a/test_a2a_agent_extension.py +++ b/tests/hosting_a2a/test_a2a_agent_extension.py @@ -1,35 +1,78 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock -from microsoft_agents.hosting.core import AgentApplication +import pytest +from a2a.server.agent_execution import RequestContext +from a2a.server.context import ServerCallContext +from a2a.server.events import EventQueue, EventQueueLegacy +from a2a.server.tasks import InMemoryTaskStore, TaskStore + +from microsoft_agents.activity import Activity +from microsoft_agents.hosting.core import ChannelAdapter, ClaimsIdentity, TurnContext from microsoft_agents.hosting.a2a.a2a_agent_extension import A2AAgentExtension +from microsoft_agents.hosting.a2a.a2a_turn_context import A2ATurnContext + + +class _Adapter(ChannelAdapter): + async def send_activities(self, context, activities): + return [] + + async def update_activity(self, context, activity): + return None + + async def delete_activity(self, context, reference): + return None + + +class _RecordingApplication: + def message(self, select, *, auth_handlers=None, **kwargs): + self.registration = (select, auth_handlers, kwargs) + + def register(handler): + self.handler = handler + return handler + + return register -def test_message_registers_wrapped_handler_with_core_application(): - app = MagicMock(spec=AgentApplication) - core_decorator = MagicMock(return_value="registered-handler") - app.message.return_value = core_decorator +def _turn_context(): + context = TurnContext( + _Adapter(), + Activity(type="message", text="hello"), + ClaimsIdentity(), + ) + context.services.set(EventQueue, EventQueueLegacy()) + context.services.set( + RequestContext, + RequestContext( + ServerCallContext(), + task_id="task-1", + context_id="context-1", + ), + ) + context.services.set(TaskStore, InMemoryTaskStore()) + return context + + +@pytest.mark.asyncio +async def test_message_handler_receives_a2a_context_and_registration_options(): + app = _RecordingApplication() extension = A2AAgentExtension(app) handler = AsyncMock() - wrapped_handler = AsyncMock() - - with patch( - "microsoft_agents.hosting.a2a.a2a_agent_extension.wrap_a2a_route_handler", - return_value=wrapped_handler, - ) as wrap_handler: - result = extension.message( - ["hello", "help"], - auth_handlers=["auth"], - rank=10, - )(handler) - - app.message.assert_called_once_with( + + registered_handler = extension.message( ["hello", "help"], auth_handlers=["auth"], rank=10, - ) - wrap_handler.assert_called_once_with(handler, app) - core_decorator.assert_called_once_with(wrapped_handler) - assert result == "registered-handler" + )(handler) + state = object() + + await registered_handler(_turn_context(), state) + + assert app.registration == (["hello", "help"], ["auth"], {"rank": 10}) + context = handler.call_args.args[0] + assert isinstance(context, A2ATurnContext) + assert context.activity.text == "hello" + assert handler.call_args.args[1] is state diff --git a/tests/hosting_a2a/test_a2a_turn_context.py b/tests/hosting_a2a/test_a2a_turn_context.py index fbe8e616c..7b1f620ab 100644 --- a/tests/hosting_a2a/test_a2a_turn_context.py +++ b/tests/hosting_a2a/test_a2a_turn_context.py @@ -72,9 +72,6 @@ def test_responded_property_is_forwarded_to_original_context(): def test_streaming_response_is_forwarded_to_original_context(): original = _original_context() - streaming_response = MagicMock() - original._streaming_response = streaming_response - context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) - assert context.streaming_response is streaming_response + assert context.streaming_response is original.streaming_response diff --git a/tests/hosting_a2a/test_add_a2a.py b/tests/hosting_a2a/test_add_a2a.py index b78849032..d36aee746 100644 --- a/tests/hosting_a2a/test_add_a2a.py +++ b/tests/hosting_a2a/test_add_a2a.py @@ -1,31 +1,40 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import importlib from types import SimpleNamespace -from unittest.mock import MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, patch -from a2a.types import AgentInterface +import httpx +import pytest +from a2a.types import AgentCapabilities, AgentCard, AgentInterface from a2a.utils.constants import TransportProtocol from fastapi import FastAPI +from microsoft_agents.hosting.core import AgentAuthConfiguration +from microsoft_agents.hosting.core.http import HttpResponse from microsoft_agents.hosting.a2a import add_a2a as exported_add_a2a from microsoft_agents.hosting.a2a.add_a2a import add_a2a -add_a2a_module = importlib.import_module("microsoft_agents.hosting.a2a.add_a2a") - def _adapter(*interfaces): return SimpleNamespace( agent_interfaces=list(interfaces), a2a_request_handler=MagicMock(), - get_agent_card=MagicMock(), + get_agent_card=AsyncMock( + return_value=AgentCard( + name="Test agent", + description="Test agent description", + version="1.0.0", + supported_interfaces=[], + capabilities=AgentCapabilities(), + ) + ), ) -def test_add_a2a_registers_jsonrpc_and_http_interfaces_with_jwt(): +@pytest.mark.asyncio +async def test_add_a2a_exposes_configured_jsonrpc_and_http_interfaces(): app = FastAPI() - agent = MagicMock() adapter = _adapter( AgentInterface( url="/rpc", @@ -36,56 +45,81 @@ def test_add_a2a_registers_jsonrpc_and_http_interfaces_with_jwt(): protocol_binding=TransportProtocol.HTTP_JSON, ), ) - jsonrpc_routes = [MagicMock()] - jsonrpc_card_routes = [MagicMock()] - rest_routes = [MagicMock()] - rest_card_routes = [MagicMock()] - - with ( - patch.object( - add_a2a_module, - "_create_jsonrpc_interface_routes", - return_value=(jsonrpc_routes, jsonrpc_card_routes), - ) as create_jsonrpc, - patch.object( - add_a2a_module, - "_create_http_interface_routes", - return_value=(rest_routes, rest_card_routes), - ) as create_http, - patch.object(add_a2a_module, "_use_jwt_middleware") as use_jwt, - patch.object( - add_a2a_module, - "add_a2a_routes_to_fastapi", - ) as register_routes, - ): - add_a2a(app, agent, adapter) - - create_jsonrpc.assert_called_once_with(adapter, adapter.agent_interfaces[0]) - create_http.assert_called_once_with(adapter, adapter.agent_interfaces[1]) - assert use_jwt.call_args_list == [ - call(jsonrpc_card_routes + rest_card_routes), - call(jsonrpc_routes), - call(rest_routes), - ] - register_routes.assert_called_once_with( - app, - agent_card_routes=jsonrpc_card_routes + rest_card_routes, - jsonrpc_routes=jsonrpc_routes, - rest_routes=rest_routes, - ) - - -def test_add_a2a_can_skip_jwt_middleware(): + add_a2a(app, MagicMock(), adapter, use_jwt_middleware=False) + + paths = {route.path for route in app.routes} + assert { + "/rpc", + "/rpc/.well-known/agent-card.json", + "/rest/message:send", + "/rest/tasks", + "/rest/.well-known/agent-card.json", + }.issubset(paths) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + rpc_card = await client.get("/rpc/.well-known/agent-card.json") + rest_card = await client.get("/rest/.well-known/agent-card.json") + + assert rpc_card.status_code == 200 + assert rest_card.status_code == 200 + assert rpc_card.json()["name"] == "Test agent" + assert rest_card.json()["name"] == "Test agent" + assert { + "/rpc", + "/rpc/.well-known/agent-card.json", + "/rest/message:send", + "/rest/tasks", + "/rest/.well-known/agent-card.json", + }.issubset(app.openapi()["paths"]) + + +@pytest.mark.asyncio +async def test_add_a2a_applies_jwt_middleware_to_registered_routes(): app = FastAPI() - adapter = _adapter() + app.state.agent_configuration = AgentAuthConfiguration() - with ( - patch.object(add_a2a_module, "_use_jwt_middleware") as use_jwt, - patch.object(add_a2a_module, "add_a2a_routes_to_fastapi"), - ): - add_a2a(app, MagicMock(), adapter, use_jwt_middleware=False) + @app.get("/health") + async def health(): + return {"status": "ok"} - use_jwt.assert_not_called() + adapter = _adapter( + AgentInterface( + url="/rpc", + protocol_binding=TransportProtocol.JSONRPC, + ) + ) + add_a2a(app, MagicMock(), adapter) + + with patch( + "microsoft_agents.hosting.fastapi.jwt_authorization_middleware." + "_authorize_request", + new=AsyncMock( + return_value=HttpResponse( + body={"error": "Authentication required"}, + status_code=401, + ) + ), + ) as authorize: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + health_response = await client.get("/health") + card_response = await client.get("/rpc/.well-known/agent-card.json") + + assert health_response.status_code == 200 + assert health_response.json() == {"status": "ok"} + assert card_response.status_code == 401 + assert card_response.json() == {"error": "Authentication required"} + authorize.assert_awaited_once() + + +def test_add_a2a_rejects_adapter_without_interfaces(): + with pytest.raises(ValueError, match="No agent interfaces found"): + add_a2a(FastAPI(), MagicMock(), _adapter()) def test_add_a2a_is_exported_from_package(): diff --git a/tests/hosting_a2a/test_blob_task_store.py b/tests/hosting_a2a/test_blob_task_store.py index 081258c45..05e22ac7d 100644 --- a/tests/hosting_a2a/test_blob_task_store.py +++ b/tests/hosting_a2a/test_blob_task_store.py @@ -2,46 +2,27 @@ # Licensed under the MIT License. import asyncio -import importlib -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +import os +import uuid +from contextlib import asynccontextmanager import pytest +from a2a.server.context import ServerCallContext from a2a.types import ListTasksRequest, Task, TaskState, TaskStatus +from azure.core.exceptions import ResourceNotFoundError +from azure.identity.aio import DefaultAzureCredential +from azure.storage.blob.aio import BlobServiceClient, ContainerClient +from dotenv import load_dotenv from google.protobuf.message import DecodeError from microsoft_agents.hosting.a2a.blob_task_store import BlobTaskStore -blob_task_store_module = importlib.import_module( - "microsoft_agents.hosting.a2a.blob_task_store" -) - - -class _AsyncIterator: - def __init__(self, values): - self._values = iter(values) - - def __aiter__(self): - return self - - async def __anext__(self): - try: - return next(self._values) - except StopIteration as error: - raise StopAsyncIteration from error - - -class _BlobPage: - def __init__(self, blob_names, continuation_token=None): - self._blobs = [SimpleNamespace(name=blob_name) for blob_name in blob_names] - self.continuation_token = continuation_token - - def __aiter__(self): - return _AsyncIterator(self._blobs) +# To enable these tests, run with --run-blob and configure either: +# TEST_BLOB_STORAGE_CONNECTION_STRING or TEST_BLOB_STORAGE_ACCOUNT_URL. def _task( - task_id: str = "task/1", + task_id: str, *, context_id: str = "context-1", state: TaskState = TaskState.TASK_STATE_WORKING, @@ -49,47 +30,52 @@ def _task( return Task( id=task_id, context_id=context_id, - status=TaskStatus( - state=state, - ), + status=TaskStatus(state=state), ) -def _store(): - container_client = MagicMock() - container_client.create_container = AsyncMock() +@asynccontextmanager +async def _blob_task_store(): + load_dotenv() + connection_string = os.environ.get("TEST_BLOB_STORAGE_CONNECTION_STRING") + credential = None + + if connection_string: + service_client = BlobServiceClient.from_connection_string(connection_string) + else: + account_url = os.environ.get("TEST_BLOB_STORAGE_ACCOUNT_URL") + if not account_url: + pytest.skip( + "Set TEST_BLOB_STORAGE_CONNECTION_STRING or " + "TEST_BLOB_STORAGE_ACCOUNT_URL" + ) + credential = DefaultAzureCredential() + service_client = BlobServiceClient(account_url, credential=credential) + + container_name = f"asdka2atasks{uuid.uuid4().hex}" + container_client = service_client.get_container_client(container_name) store = BlobTaskStore(container_client) - store._initialized = True - return store, container_client - - -def test_constructor_uses_provided_container_client(): - container_client = MagicMock() - store = BlobTaskStore(container_client) - - assert store._container_client is container_client - assert store._initialized is False - - -def test_constructor_creates_container_client_from_connection_string(): - blob_service_client = MagicMock() - container_client = MagicMock() - blob_service_client.get_container_client.return_value = container_client - - with patch.object( - blob_task_store_module.BlobServiceClient, - "from_connection_string", - return_value=blob_service_client, - ) as from_connection_string: - store = BlobTaskStore( - data_connection_string="UseDevelopmentStorage=true", - container_name="tasks", - ) - - from_connection_string.assert_called_once_with("UseDevelopmentStorage=true") - blob_service_client.get_container_client.assert_called_once_with("tasks") - assert store._container_client is container_client + try: + yield store, container_client + finally: + try: + await container_client.delete_container() + except ResourceNotFoundError: + pass + await container_client.close() + await service_client.close() + if credential: + await credential.close() + + +async def _upload_blob(container_client: ContainerClient, name: str, data: bytes): + blob_client = await container_client.upload_blob( + name=name, + data=data, + overwrite=True, + ) + await blob_client.close() @pytest.mark.parametrize( @@ -105,210 +91,141 @@ def test_constructor_rejects_invalid_parameter_combinations(kwargs): BlobTaskStore(**kwargs) -@pytest.mark.asyncio -async def test_ensure_container_exists_initializes_only_once(): - container_client = MagicMock() - container_client.create_container = AsyncMock() - store = BlobTaskStore(container_client) - - await asyncio.gather( - store._ensure_container_exists(), - store._ensure_container_exists(), - store._ensure_container_exists(), - ) - - container_client.create_container.assert_awaited_once() - assert store._initialized is True - - -@pytest.mark.parametrize( - ("task_id", "expected"), - [ - ("task-1", "TODOtask-1"), - ("task/1", "TODOtask%2F1"), - ("task with spaces", "TODOtask+with+spaces"), - ("task+plus", "TODOtask%2Bplus"), - ], -) -def test_get_blob_name_encodes_task_id(task_id, expected): - assert BlobTaskStore._get_blob_name(task_id) == expected - - -def test_get_blob_name_rejects_empty_task_id(): - with pytest.raises(ValueError, match="Task ID cannot be empty"): - BlobTaskStore._get_blob_name("") - - -@pytest.mark.asyncio -async def test_save_uploads_serialized_protobuf(): - store, container_client = _store() - blob_client = MagicMock() - blob_client.upload_blob = AsyncMock() - container_client.get_blob_client.return_value = blob_client - task = _task() - - await store.save(task, context=MagicMock()) - - container_client.get_blob_client.assert_called_once_with("TODOtask%2F1") - blob_client.upload_blob.assert_awaited_once_with( - data=task.SerializeToString(), - overwrite=True, - length=len(task.SerializeToString()), - ) - - -@pytest.mark.asyncio -async def test_save_initializes_container_before_upload(): - container_client = MagicMock() - container_client.create_container = AsyncMock() - blob_client = MagicMock() - blob_client.upload_blob = AsyncMock() - container_client.get_blob_client.return_value = blob_client - store = BlobTaskStore(container_client) - - await store.save(_task(), context=MagicMock()) - - container_client.create_container.assert_awaited_once() - blob_client.upload_blob.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_get_deserializes_protobuf_task(): - store, container_client = _store() - task = _task() - downloader = SimpleNamespace( - readall=AsyncMock(return_value=task.SerializeToString()) - ) - container_client.download_blob = AsyncMock(return_value=downloader) - - result = await store.get(task.id, context=MagicMock()) - - assert result == task - assert result is not task - container_client.download_blob.assert_awaited_once_with( - blob="TODOtask%2F1", - timeout=5, - ) - - -@pytest.mark.asyncio -async def test_download_task_blob_uses_existing_blob_name(): - store, container_client = _store() - task = _task() - downloader = SimpleNamespace( - readall=AsyncMock(return_value=task.SerializeToString()) - ) - container_client.download_blob = AsyncMock(return_value=downloader) - - result = await store._download_task_blob("TODOtask%2F1") - - assert result == task - container_client.download_blob.assert_awaited_once_with( - blob="TODOtask%2F1", - timeout=5, - ) - - -@pytest.mark.asyncio -async def test_get_returns_none_when_blob_does_not_exist(): - store, container_client = _store() - container_client.download_blob = AsyncMock(return_value=None) - - assert await store.get("missing", context=MagicMock()) is None - - -@pytest.mark.asyncio -async def test_get_propagates_invalid_protobuf_data(): - store, container_client = _store() - downloader = SimpleNamespace(readall=AsyncMock(return_value=b"not protobuf")) - container_client.download_blob = AsyncMock(return_value=downloader) - - with pytest.raises(DecodeError): - await store.get("invalid", context=MagicMock()) - - -@pytest.mark.parametrize( - ("params", "expected"), - [ - (ListTasksRequest(), True), - ( - ListTasksRequest(status=TaskState.TASK_STATE_WORKING), - True, - ), - ( - ListTasksRequest(status=TaskState.TASK_STATE_COMPLETED), - False, - ), - ( - ListTasksRequest(context_id="context-1"), - True, - ), - ( - ListTasksRequest(context_id="other-context"), - False, - ), - ], -) -def test_should_include_task_filters_by_status_and_context(params, expected): - assert BlobTaskStore._should_include_task(_task(), params) is expected - - -@pytest.mark.asyncio -async def test_list_returns_filtered_tasks_and_continuation_token(): - store, container_client = _store() - page = _BlobPage( - ["TODOtask-1", "TODOtask-2", "TODOmissing"], - continuation_token="next-page", - ) - items = MagicMock() - items.by_page.return_value = _AsyncIterator([page]) - container_client.list_blobs.return_value = items - matching_task = _task("task-1") - other_context_task = _task("task-2", context_id="other-context") - store._download_task_blob = AsyncMock( - side_effect=[matching_task, other_context_task, None] - ) - params = ListTasksRequest( - status=TaskState.TASK_STATE_WORKING, - context_id="context-1", - page_size=25, - page_token="current-page", - ) - - response = await store.list(params, context=MagicMock()) - - container_client.list_blobs.assert_called_once_with( - name_starts_with="TODO", - results_per_page=25, - ) - items.by_page.assert_called_once_with("current-page") - assert list(response.tasks) == [matching_task] - assert response.next_page_token == "next-page" - assert store._download_task_blob.await_count == 3 - - -@pytest.mark.asyncio -async def test_list_returns_empty_response_for_empty_page(): - store, container_client = _store() - page = _BlobPage([]) - items = MagicMock() - items.by_page.return_value = _AsyncIterator([page]) - container_client.list_blobs.return_value = items - - response = await store.list(ListTasksRequest(), context=MagicMock()) - - assert list(response.tasks) == [] - assert response.next_page_token == "" - - -@pytest.mark.asyncio -async def test_delete_deletes_encoded_blob_name(): - store, container_client = _store() - blob_client = MagicMock() - blob_client.delete_blob = AsyncMock() - container_client.get_blob_client.return_value = blob_client - - await store.delete("task/1", context=MagicMock()) - - container_client.get_blob_client.assert_called_once_with("TODOtask%2F1") - blob_client.delete_blob.assert_awaited_once() +@pytest.mark.blob +class TestBlobTaskStore: + @pytest.mark.asyncio + async def test_save_get_and_overwrite_task(self): + context = ServerCallContext() + + async with _blob_task_store() as (store, container_client): + task = _task("task/with spaces") + + await store.save(task, context) + + saved = await store.get(task.id, context) + assert saved == task + assert saved is not task + + blob_names = [blob.name async for blob in container_client.list_blobs()] + assert blob_names == ["TODOtask%2Fwith+spaces"] + + task.status.state = TaskState.TASK_STATE_COMPLETED + await store.save(task, context) + + overwritten = await store.get(task.id, context) + assert overwritten == task + assert overwritten.status.state == TaskState.TASK_STATE_COMPLETED + + @pytest.mark.asyncio + async def test_external_blob_change_is_visible(self): + context = ServerCallContext() + external_task = _task("external-task") + + async with _blob_task_store() as (store, container_client): + assert await store.get(external_task.id, context) is None + + await _upload_blob( + container_client, + "TODOexternal-task", + external_task.SerializeToString(), + ) + + assert await store.get(external_task.id, context) == external_task + + @pytest.mark.asyncio + async def test_get_returns_none_for_missing_task(self): + async with _blob_task_store() as (store, _): + assert await store.get("missing-task", ServerCallContext()) is None + + @pytest.mark.asyncio + async def test_get_rejects_corrupted_task_blob(self): + context = ServerCallContext() + + async with _blob_task_store() as (store, container_client): + assert await store.get("corrupted-task", context) is None + await _upload_blob( + container_client, + "TODOcorrupted-task", + b"not a serialized Task", + ) + + with pytest.raises(DecodeError): + await store.get("corrupted-task", context) + + @pytest.mark.asyncio + async def test_list_filters_by_status_and_context(self): + context = ServerCallContext() + matching = _task("task-1") + tasks = [ + matching, + _task("task-2", context_id="other-context"), + _task("task-3", state=TaskState.TASK_STATE_COMPLETED), + ] + + async with _blob_task_store() as (store, _): + await asyncio.gather(*(store.save(task, context) for task in tasks)) + + response = await store.list( + ListTasksRequest( + status=TaskState.TASK_STATE_WORKING, + context_id="context-1", + ), + context, + ) + + assert list(response.tasks) == [matching] + assert response.next_page_token == "" + + @pytest.mark.asyncio + async def test_list_uses_azure_continuation_tokens(self): + context = ServerCallContext() + tasks = [_task(f"task-{index}") for index in range(3)] + + async with _blob_task_store() as (store, _): + await asyncio.gather(*(store.save(task, context) for task in tasks)) + + first_page = await store.list( + ListTasksRequest(page_size=2), + context, + ) + second_page = await store.list( + ListTasksRequest( + page_size=2, + page_token=first_page.next_page_token, + ), + context, + ) + + assert [task.id for task in first_page.tasks] == [ + "task-0", + "task-1", + ] + assert first_page.next_page_token + assert [task.id for task in second_page.tasks] == ["task-2"] + assert second_page.next_page_token == "" + + @pytest.mark.asyncio + async def test_concurrent_first_operations_share_container_initialization(self): + context = ServerCallContext() + tasks = [_task(f"task-{index}") for index in range(3)] + + async with _blob_task_store() as (store, _): + await asyncio.gather(*(store.save(task, context) for task in tasks)) + saved = await asyncio.gather( + *(store.get(task.id, context) for task in tasks) + ) + + assert saved == tasks + + @pytest.mark.asyncio + async def test_delete_removes_persisted_task(self): + context = ServerCallContext() + task = _task("task/to-delete") + + async with _blob_task_store() as (store, _): + await store.save(task, context) + assert await store.get(task.id, context) == task + + await store.delete(task.id, context) + + assert await store.get(task.id, context) is None From 8cd0f09d23fa47683d77c085c11c3785b6eba2f1 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 14:27:04 -0700 Subject: [PATCH 27/39] Adding jwt e2e test for a2a --- .../jwt_validation/test_a2a_jwt_validation.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 dev/integration/tests/jwt_validation/test_a2a_jwt_validation.py diff --git a/dev/integration/tests/jwt_validation/test_a2a_jwt_validation.py b/dev/integration/tests/jwt_validation/test_a2a_jwt_validation.py new file mode 100644 index 000000000..ddb50b4b4 --- /dev/null +++ b/dev/integration/tests/jwt_validation/test_a2a_jwt_validation.py @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import uuid +from unittest.mock import MagicMock + +import pytest +from a2a.types import AgentInterface, Message, Part, Role, SendMessageRequest +from a2a.utils.constants import TransportProtocol +from fastapi import FastAPI +from fastapi.testclient import TestClient +from google.protobuf.json_format import MessageToDict + +from microsoft_agents.activity import Activity, ActivityTypes, EndOfConversationCodes +from microsoft_agents.hosting.a2a import A2AAdapter, add_a2a +from microsoft_agents.hosting.core import ( + AgentApplication, + ApplicationOptions, + MemoryStorage, + TurnContext, + TurnState, +) + +from tests.utils.config import REAL_SERVICE_CONNECTION_ENV_VARS +from tests.utils.pytest import skip_if_no_var + +from ._helpers import ( + acquire_real_service_connection_token, + auth_config_with_invalid_audience, +) + +_requires_real_service_connection = skip_if_no_var( + *REAL_SERVICE_CONNECTION_ENV_VARS, load_root_env_file=True +) + + +def _rpc_request() -> dict: + request = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id=str(uuid.uuid4()), + parts=[Part(text="hello")], + ) + ) + return { + "jsonrpc": "2.0", + "id": "request-1", + "method": "SendMessage", + "params": MessageToDict(request), + } + + +def _create_app(auth_config): + observed = {} + agent = AgentApplication[TurnState]( + options=ApplicationOptions(storage=MemoryStorage()), + connection_manager=MagicMock(), + ) + + @agent.activity(ActivityTypes.message) + async def on_message(context: TurnContext, _state: TurnState) -> None: + observed["identity"] = context.identity + await context.send_activity("Authenticated") + await context.send_activity( + Activity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + ) + ) + + app = FastAPI() + app.state.agent_configuration = auth_config + adapter = A2AAdapter( + agent, + agent_interfaces=[ + AgentInterface( + url="/a2a", + protocol_binding=TransportProtocol.JSONRPC, + ) + ], + ) + add_a2a(app, agent, adapter=adapter) + return app, observed + + +@_requires_real_service_connection +@pytest.mark.asyncio +async def test_a2a_accepts_real_service_connection_token_and_forwards_identity(): + token, auth_config = await acquire_real_service_connection_token() + app, observed = _create_app(auth_config) + + with TestClient(app) as client: + response = client.post( + "/a2a", + json=_rpc_request(), + headers={ + "A2A-Version": "1.0", + "Authorization": f"Bearer {token}", + }, + ) + + assert response.status_code == 200 + assert response.json()["result"]["task"]["status"]["state"] == ( + "TASK_STATE_COMPLETED" + ) + identity = observed["identity"] + assert identity.allow_anonymous is False + assert identity.claims + + +@_requires_real_service_connection +@pytest.mark.asyncio +async def test_a2a_rejects_real_token_with_invalid_audience(): + token, auth_config = await acquire_real_service_connection_token() + app, observed = _create_app(auth_config_with_invalid_audience(auth_config)) + + with TestClient(app) as client: + response = client.post( + "/a2a", + json=_rpc_request(), + headers={ + "A2A-Version": "1.0", + "Authorization": f"Bearer {token}", + }, + ) + + assert response.status_code == 401 + assert response.json() == {"error": "Invalid token or authentication failed."} + assert "identity" not in observed From 5d6c519bb73e63007f6528a2d5248aac592ccfbf Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 14:27:17 -0700 Subject: [PATCH 28/39] Better tests --- .../microsoft_agents/hosting/a2a/add_a2a.py | 12 ++ .../hosting_a2a/activity/test_a2a_activity.py | 26 ++++ tests/hosting_a2a/integration/conftest.py | 25 ++++ .../hosting_a2a/integration/test_protocol.py | 133 +++++++++++++++++- .../request_handling/test_request_handling.py | 18 +++ tests/hosting_a2a/server/test_routes.py | 38 +++++ .../test_sdk_server_call_context_builder.py | 14 +- tests/hosting_a2a/test_a2a_adapter.py | 31 ++++ tests/hosting_a2a/test_a2a_agent_extension.py | 14 ++ tests/hosting_a2a/test_a2a_turn_context.py | 29 ++++ tests/hosting_a2a/test_add_a2a.py | 34 +++++ 11 files changed, 369 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index cb6e9afbe..375fd579a 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import logging + from fastapi import FastAPI from starlette.routing import BaseRoute, Route @@ -18,6 +20,7 @@ use_jwt_middleware as _use_jwt_middleware, ) +logger = logging.getLogger(__name__) def _create_jsonrpc_interface_routes( adapter: A2AAdapter, @@ -83,6 +86,7 @@ def add_a2a( "No agent interfaces found. Cannot add A2A routes to application." ) + unsupported_counter = 0 for interface in interfaces: if interface.protocol_binding == TransportProtocol.JSONRPC: _jsonrpc_routes, _agent_card_routes = _create_jsonrpc_interface_routes( @@ -96,6 +100,14 @@ def add_a2a( ) rest_routes.extend(_http_routes) agent_card_routes.extend(_agent_card_routes) + else: + unsupported_counter += 1 + logger.warning("Unsupported protocol binding: %s", interface.protocol_binding) + + if unsupported_counter == len(interfaces): + raise ValueError( + "All agent interfaces have unsupported protocol bindings. Cannot add A2A routes to application." + ) if use_jwt_middleware: _use_jwt_middleware(agent_card_routes) diff --git a/tests/hosting_a2a/activity/test_a2a_activity.py b/tests/hosting_a2a/activity/test_a2a_activity.py index 72f5cc38c..c275d7d48 100644 --- a/tests/hosting_a2a/activity/test_a2a_activity.py +++ b/tests/hosting_a2a/activity/test_a2a_activity.py @@ -4,6 +4,7 @@ import pytest from a2a.types import Message, Part, Role, TaskState from google.protobuf.json_format import ParseDict +from google.protobuf.json_format import MessageToDict from google.protobuf.struct_pb2 import Value from microsoft_agents.activity import ( @@ -94,6 +95,31 @@ def test_from_message_maps_all_supported_part_types(): assert data_attachment.name == "A2A DataPart" +def test_supported_parts_round_trip_through_activity_artifact(): + message = _message( + Part( + url="https://example.com/image.png", + media_type="image/png", + filename="image.png", + ), + Part( + raw=b"file contents", + media_type="application/octet-stream", + filename="data.bin", + ), + Part(data=ParseDict({"answer": 42}, Value())), + ) + + activity = A2AActivity.from_message("request-1", None, message) + artifact = activity.to_artifact("artifact-1") + + assert artifact.parts[0].url == "https://example.com/image.png" + assert artifact.parts[0].media_type == "image/png" + assert artifact.parts[1].raw == b"file contents" + assert artifact.parts[1].media_type == "application/octet-stream" + assert MessageToDict(artifact.parts[2].data) == {"answer": 42.0} + + def test_to_message_and_artifact_delegate_activity_content(): activity = A2AActivity(type=ActivityTypes.message, text="result") diff --git a/tests/hosting_a2a/integration/conftest.py b/tests/hosting_a2a/integration/conftest.py index d6f3312d2..d00e953cb 100644 --- a/tests/hosting_a2a/integration/conftest.py +++ b/tests/hosting_a2a/integration/conftest.py @@ -23,6 +23,12 @@ STREAMING_TRIGGER_TEXT = "stream-me" """Message text that causes the test agent to emit chunked streaming updates.""" +INPUT_REQUIRED_TRIGGER_TEXT = "need-more-input" +"""Message text that leaves the task waiting for a continuation.""" + +STRUCTURED_RESULT_TRIGGER_TEXT = "structured-result" +"""Message text that completes with both text and structured result data.""" + def _create_agent_application() -> AgentApplication[TurnState]: application = AgentApplication[TurnState]( @@ -42,6 +48,25 @@ async def on_message(context: TurnContext, _state: TurnState) -> None: streaming_response.queue_text_chunk("Echo: ") streaming_response.queue_text_chunk(text) await streaming_response.end_stream() + elif text == INPUT_REQUIRED_TRIGGER_TEXT: + await context.send_activity( + Activity( + type=ActivityTypes.message, + text="More information required", + input_hint="expectingInput", + ) + ) + return + elif text == STRUCTURED_RESULT_TRIGGER_TEXT: + await context.send_activity( + Activity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + text="Completed with structured data", + value={"answer": 42}, + ) + ) + return else: await context.send_activity(f"Echo: {text}") await context.send_activity( diff --git a/tests/hosting_a2a/integration/test_protocol.py b/tests/hosting_a2a/integration/test_protocol.py index c6fe62ff6..134d00a62 100644 --- a/tests/hosting_a2a/integration/test_protocol.py +++ b/tests/hosting_a2a/integration/test_protocol.py @@ -21,23 +21,41 @@ SendMessageRequest, ) -from .conftest import STREAMING_TRIGGER_TEXT +from .conftest import ( + INPUT_REQUIRED_TRIGGER_TEXT, + STREAMING_TRIGGER_TEXT, + STRUCTURED_RESULT_TRIGGER_TEXT, +) pytestmark = pytest.mark.filterwarnings( "ignore:label\\(\\) is deprecated\\. Use is_required\\(\\) or is_repeated\\(\\) instead\\.:DeprecationWarning" ) -def _message(text: str = "hello") -> Message: +def _message( + text: str = "hello", + *, + task_id: str = "", + context_id: str = "", +) -> Message: return Message( role=Role.ROLE_USER, message_id=str(uuid.uuid4()), + task_id=task_id, + context_id=context_id, parts=[Part(text=text)], ) -def _send_request(text: str = "hello") -> SendMessageRequest: - return SendMessageRequest(message=_message(text)) +def _send_request( + text: str = "hello", + *, + task_id: str = "", + context_id: str = "", +) -> SendMessageRequest: + return SendMessageRequest( + message=_message(text, task_id=task_id, context_id=context_id) + ) def _rpc_request(method: str, params: object, request_id: str = "request-1") -> dict: @@ -87,6 +105,24 @@ async def _stream_rpc( return events +async def _stream_rest( + client: httpx.AsyncClient, + params: SendMessageRequest, +) -> list[dict]: + events: list[dict] = [] + async with client.stream( + "POST", + "/rest/message:stream", + json=MessageToDict(params), + headers={"A2A-Version": "1.0", "Accept": "text/event-stream"}, + ) as response: + assert response.status_code == 200 + async for line in response.aiter_lines(): + if line.startswith("data:"): + events.append(json.loads(line[len("data:") :].strip())) + return events + + def _task_id(response: dict) -> str: return response["result"]["task"]["id"] @@ -178,6 +214,65 @@ async def test_rest_message_send_get_and_list_match_jsonrpc_semantics( assert any(item["id"] == task_id for item in list_response.json()["tasks"]) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jsonrpc_continues_input_required_task( + a2a_client: httpx.AsyncClient, +) -> None: + first_response = await _post_rpc( + a2a_client, + "SendMessage", + _send_request(INPUT_REQUIRED_TRIGGER_TEXT), + ) + first_task = first_response["result"]["task"] + assert first_task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + + second_response = await _post_rpc( + a2a_client, + "SendMessage", + _send_request( + "additional input", + task_id=first_task["id"], + context_id=first_task["contextId"], + ), + request_id="continue-task", + ) + second_task = second_response["result"]["task"] + + assert second_task["id"] == first_task["id"] + assert second_task["contextId"] == first_task["contextId"] + assert second_task["status"]["state"] == "TASK_STATE_COMPLETED" + assert [message["parts"][0]["text"] for message in second_task["history"]] == [ + INPUT_REQUIRED_TRIGGER_TEXT, + "More information required", + "additional input", + "Echo: additional input", + ] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jsonrpc_returns_structured_completion_artifact_and_status_message( + a2a_client: httpx.AsyncClient, +) -> None: + response = await _post_rpc( + a2a_client, + "SendMessage", + _send_request(STRUCTURED_RESULT_TRIGGER_TEXT), + ) + task = response["result"]["task"] + + assert task["status"]["state"] == "TASK_STATE_COMPLETED" + assert task["status"]["message"]["parts"] == [ + {"text": "Completed with structured data"} + ] + assert task["artifacts"][0]["name"] == "Result" + result_part = task["artifacts"][0]["parts"][0] + assert result_part["data"] == {"answer": 42.0} + assert result_part["metadata"]["mimeType"] == "application/json" + assert result_part["metadata"]["type"] == "object" + + @pytest.mark.integration @pytest.mark.asyncio async def test_jsonrpc_cancel_missing_task_returns_protocol_error( @@ -260,6 +355,36 @@ async def test_jsonrpc_streaming_message_emits_ordered_task_events( assert get_response["result"]["status"]["state"] == "TASK_STATE_COMPLETED" +@pytest.mark.integration +@pytest.mark.asyncio +async def test_rest_streaming_message_emits_ordered_task_events( + a2a_client: httpx.AsyncClient, +) -> None: + events = await _stream_rest( + a2a_client, + _send_request(STREAMING_TRIGGER_TEXT), + ) + + task = events[0]["task"] + task_id = task["id"] + assert task["status"]["state"] == "TASK_STATE_SUBMITTED" + + working_update = events[1]["statusUpdate"] + assert working_update["taskId"] == task_id + assert working_update["status"]["state"] == "TASK_STATE_WORKING" + assert working_update["status"]["message"]["parts"][0]["text"] == "Thinking..." + + artifact_update = events[2]["artifactUpdate"] + assert artifact_update["taskId"] == task_id + assert artifact_update["artifact"]["parts"][0]["text"] == ( + f"Echo: {STREAMING_TRIGGER_TEXT}" + ) + + final_update = events[-1]["statusUpdate"] + assert final_update["taskId"] == task_id + assert final_update["status"]["state"] == "TASK_STATE_COMPLETED" + + @pytest.mark.integration @pytest.mark.asyncio async def test_jsonrpc_push_notification_config_is_explicitly_unsupported( diff --git a/tests/hosting_a2a/request_handling/test_request_handling.py b/tests/hosting_a2a/request_handling/test_request_handling.py index 34feb48bb..f742010f7 100644 --- a/tests/hosting_a2a/request_handling/test_request_handling.py +++ b/tests/hosting_a2a/request_handling/test_request_handling.py @@ -205,6 +205,24 @@ async def cancel_agent_turn(self, context, event_queue) -> None: raise AssertionError("Failed tasks should not be canceled") +@pytest.mark.asyncio +async def test_executor_drops_request_without_message(): + adapter = _CompletingAdapter() + executor = A2AAgentExecutor(adapter) + context = RequestContext( + ServerCallContext(), + task_id="task-1", + context_id="context-1", + ) + event_queue = EventQueueLegacy() + + await executor.execute(context, event_queue) + + assert adapter.contexts == [] + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(event_queue.dequeue_event(), timeout=0.05) + + @pytest.mark.asyncio async def test_executor_establishes_task_mode_before_adapter_events(): adapter = _CompletingAdapter() diff --git a/tests/hosting_a2a/server/test_routes.py b/tests/hosting_a2a/server/test_routes.py index 57889dac0..67d96c2cb 100644 --- a/tests/hosting_a2a/server/test_routes.py +++ b/tests/hosting_a2a/server/test_routes.py @@ -131,6 +131,44 @@ async def get_agent_card(request, prefix): assert observed["prefix"] == "/a2a" +@pytest.mark.asyncio +async def test_create_agent_card_routes_uses_custom_url_as_prefix(): + observed = {} + + async def get_agent_card(request, prefix): + observed["prefix"] = prefix + return AgentCard( + name="Test agent", + description="Test description", + version="1.0.0", + supported_interfaces=[], + capabilities=AgentCapabilities(), + ) + + route = routes.create_agent_card_routes( + get_agent_card, + "/custom-agent-card", + )[0] + request = Request( + { + "type": "http", + "method": "GET", + "path": "/custom-agent-card", + "raw_path": b"/custom-agent-card", + "query_string": b"", + "headers": [], + "scheme": "https", + "server": ("example.com", 443), + "client": ("127.0.0.1", 1234), + } + ) + + response = await route.endpoint(request) + + assert response.status_code == 200 + assert observed["prefix"] == "/custom-agent-card" + + @pytest.mark.asyncio async def test_use_jwt_middleware_authorizes_shared_mounted_route_once(): async def endpoint(request): diff --git a/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py b/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py index 96d96f6ab..c70c221dd 100644 --- a/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py +++ b/tests/hosting_a2a/server/test_sdk_server_call_context_builder.py @@ -11,7 +11,7 @@ ) -def _request(*, claims_identity=None): +def _request(*, claims_identity=None, auth=None): scope = { "type": "http", "method": "GET", @@ -29,6 +29,8 @@ def _request(*, claims_identity=None): } if claims_identity is not None: scope["state"]["claims_identity"] = claims_identity + if auth is not None: + scope["auth"] = auth return Request(scope) @@ -53,3 +55,13 @@ def test_build_omits_claims_identity_when_middleware_did_not_set_it(): assert isinstance(context.user, UnauthenticatedUser) assert _CLAIMS_IDENTITY_KEY not in context.state + + +def test_build_copies_asgi_auth_scope(): + auth = object() + request = _request(auth=auth) + builder = SDKServerCallContextBuilder() + + context = builder.build(request) + + assert context.state["auth"] is auth diff --git a/tests/hosting_a2a/test_a2a_adapter.py b/tests/hosting_a2a/test_a2a_adapter.py index 1ef16e33c..81eb8f083 100644 --- a/tests/hosting_a2a/test_a2a_adapter.py +++ b/tests/hosting_a2a/test_a2a_adapter.py @@ -22,6 +22,7 @@ TaskStatusUpdateEvent, ) from a2a.utils.constants import TransportProtocol +from google.protobuf.json_format import MessageToDict from microsoft_agents.activity import ( Activity, @@ -330,6 +331,36 @@ async def test_end_of_conversation_maps_terminal_state(code, expected_state): assert event.status.state == expected_state +@pytest.mark.asyncio +async def test_end_of_conversation_emits_result_artifact_before_status_message(): + event_queue = EventQueueLegacy() + adapter = A2AAdapter(_agent()) + context = _turn_context(adapter, event_queue) + activity = A2AActivity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + text="Completed with structured data", + value={"answer": 42}, + ) + + await adapter.send_activities(context, [activity]) + + artifact_event = await event_queue.dequeue_event() + status_event = await event_queue.dequeue_event() + + assert isinstance(artifact_event, TaskArtifactUpdateEvent) + assert artifact_event.last_chunk is True + assert artifact_event.artifact.name == "Result" + assert MessageToDict(artifact_event.artifact.parts[0].data) == {"answer": 42.0} + + assert isinstance(status_event, TaskStatusUpdateEvent) + assert status_event.status.state == TaskState.TASK_STATE_COMPLETED + assert status_event.status.message.parts[0].text == ( + "Completed with structured data" + ) + assert all(not part.HasField("data") for part in status_event.status.message.parts) + + @pytest.mark.asyncio async def test_get_agent_card_projects_interfaces_and_skills(): interface = AgentInterface( diff --git a/tests/hosting_a2a/test_a2a_agent_extension.py b/tests/hosting_a2a/test_a2a_agent_extension.py index 4386a9857..3433552e4 100644 --- a/tests/hosting_a2a/test_a2a_agent_extension.py +++ b/tests/hosting_a2a/test_a2a_agent_extension.py @@ -76,3 +76,17 @@ async def test_message_handler_receives_a2a_context_and_registration_options(): assert isinstance(context, A2ATurnContext) assert context.activity.text == "hello" assert handler.call_args.args[1] is state + + +@pytest.mark.asyncio +async def test_message_handler_reuses_existing_a2a_context(): + app = _RecordingApplication() + extension = A2AAgentExtension(app) + handler = AsyncMock() + registered_handler = extension.message([])(handler) + context = A2ATurnContext(_turn_context(), app) + state = object() + + await registered_handler(context, state) + + assert handler.call_args.args == (context, state) diff --git a/tests/hosting_a2a/test_a2a_turn_context.py b/tests/hosting_a2a/test_a2a_turn_context.py index 7b1f620ab..710ca35e5 100644 --- a/tests/hosting_a2a/test_a2a_turn_context.py +++ b/tests/hosting_a2a/test_a2a_turn_context.py @@ -60,6 +60,35 @@ def test_wraps_existing_context_and_exposes_a2a_client(): assert context.client.task_store is original.services.get(TaskStore) +def test_wrapped_context_preserves_original_turn_state(): + original = _original_context() + marker = object() + original.turn_state["marker"] = marker + + context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) + + assert context.turn_state is original.turn_state + assert context.turn_state["marker"] is marker + + +def test_can_be_constructed_directly_from_adapter_activity_and_identity(): + adapter = _Adapter() + activity = Activity(type="message", text="hello") + identity = ClaimsIdentity({"sub": "agent-1"}) + + context = A2ATurnContext( + adapter, + MagicMock(spec=AgentApplication), + activity, + identity, + ) + + assert context.adapter is adapter + assert context.activity is activity + assert isinstance(context.activity, A2AActivity) + assert context.identity is identity + + def test_responded_property_is_forwarded_to_original_context(): original = _original_context() context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) diff --git a/tests/hosting_a2a/test_add_a2a.py b/tests/hosting_a2a/test_add_a2a.py index d36aee746..3b3198b14 100644 --- a/tests/hosting_a2a/test_add_a2a.py +++ b/tests/hosting_a2a/test_add_a2a.py @@ -122,5 +122,39 @@ def test_add_a2a_rejects_adapter_without_interfaces(): add_a2a(FastAPI(), MagicMock(), _adapter()) +def test_add_a2a_rejects_adapter_without_supported_interfaces(): + adapter = _adapter( + AgentInterface( + url="/grpc", + protocol_binding=TransportProtocol.GRPC, + ) + ) + + with pytest.raises(ValueError, match="unsupported protocol bindings"): + add_a2a(FastAPI(), MagicMock(), adapter, use_jwt_middleware=False) + + +def test_add_a2a_ignores_unsupported_interfaces_when_supported_ones_exist(): + app = FastAPI() + adapter = _adapter( + AgentInterface( + url="/grpc", + protocol_binding=TransportProtocol.GRPC, + ), + AgentInterface( + url="/rpc", + protocol_binding=TransportProtocol.JSONRPC, + ), + ) + + add_a2a(app, MagicMock(), adapter, use_jwt_middleware=False) + + paths = {route.path for route in app.routes} + assert "/rpc" in paths + assert "/rpc/.well-known/agent-card.json" in paths + assert "/grpc" not in paths + assert "/grpc/.well-known/agent-card.json" not in paths + + def test_add_a2a_is_exported_from_package(): assert exported_add_a2a is add_a2a From 1b5aa0a34d7506d36806ebf095f43ef7ddf92b2d Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 15:25:57 -0700 Subject: [PATCH 29/39] A2ATurnContext initialization improvements and more tests --- dev/integration/pyproject.toml | 1 + .../hosting/a2a/a2a_adapter.py | 31 ++++++--- .../hosting/a2a/a2a_turn_context.py | 66 ++++++++++++++----- .../hosting/a2a/activity/a2a_activity.py | 24 +++---- .../hosting/a2a/activity/utils.py | 11 +++- .../microsoft_agents/hosting/a2a/add_a2a.py | 5 +- .../hosting/a2a/route_handlers.py | 2 +- .../hosting_a2a/activity/test_a2a_activity.py | 39 +++++++++-- tests/hosting_a2a/activity/test_utils.py | 8 ++- tests/hosting_a2a/test_a2a_agent_extension.py | 2 +- tests/hosting_a2a/test_a2a_turn_context.py | 35 ++++++++-- 11 files changed, 170 insertions(+), 54 deletions(-) diff --git a/dev/integration/pyproject.toml b/dev/integration/pyproject.toml index c29a72124..345d28c01 100644 --- a/dev/integration/pyproject.toml +++ b/dev/integration/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "pytest-asyncio", "microsoft-agents-authentication-msal", "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-a2a @ file:///${PROJECT_ROOT}/../../libraries/microsoft-agents-hosting-a2a", "microsoft-agents-hosting-dialogs", "microsoft-agents-hosting-fastapi", "microsoft-agents-hosting-testing @ file:///${PROJECT_ROOT}/../microsoft-agents-hosting-testing", diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index 9f07758f4..51333a6fd 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -39,7 +39,7 @@ StreamInfo, ) from microsoft_agents.hosting.core import ( - Agent, + AgentApplication, AuthenticationConstants, ChannelAdapter, ChannelServiceAdapter, @@ -61,6 +61,7 @@ from .activity import utils, A2AActivity from .activity.a2a_activity import _DEFAULT_USER_ID +from .a2a_turn_context import A2ATurnContext from .server._constants import _CLAIMS_IDENTITY_KEY @@ -72,7 +73,7 @@ class A2AAdapter(A2AHttpAdapter, ChannelAdapter, ChannelAdapterProtocol): def __init__( self, - agent: Agent, + agent: AgentApplication, *, agent_card_name: str = "A2AAdapter", agent_card_description: str = "Agents SDK A2A", @@ -192,17 +193,31 @@ async def cancel_agent_turn( def _create_turn_context( self, claims_identity: ClaimsIdentity, - oauth_scope: str | None = None, - activity: Activity | None = None, + oauth_scope: str | None, + activity: Activity, + request_context: RequestContext, + event_queue: EventQueue, + task_store: TaskStore, ) -> TurnContext: """Create a turn context for the given claims identity, OAuth scope, and activity. :param claims_identity: The claims identity for the turn context. :param oauth_scope: The OAuth scope for the turn context. :param activity: The activity for the turn context. + :param request_context: The request context for the turn context. + :param event_queue: The event queue for the turn context. + :param task_store: The task store for the turn context. :return: The created turn context. """ - context = TurnContext(self, activity, claims_identity) + context = A2ATurnContext( + self, + app=self._agent, + activity=activity, + identity=claims_identity, + request_context=request_context, + event_queue=event_queue, + task_store=task_store, + ) context.turn_state[ChannelServiceAdapter.OAUTH_SCOPE_KEY] = oauth_scope context.turn_state[ChannelServiceAdapter.AGENT_IDENTITY_KEY] = ( claims_identity # for back-compat @@ -241,10 +256,10 @@ async def _process_activity_with_a2a( identity, outgoing_audience, activity=activity, + request_context=request_context, + event_queue=event_queue, + task_store=self._task_store, ) - context.services.set(RequestContext, request_context) - context.services.set(EventQueue, event_queue) - context.services.set(TaskStore, self._task_store) await self.run_pipeline(context, self._agent.on_turn) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py index 357457f48..cb0991055 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py @@ -5,11 +5,15 @@ from typing import cast +from a2a.server.agent_execution import RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskStore + from microsoft_agents.activity import Activity from microsoft_agents.hosting.core import ( AgentApplication, TurnContext, - ChannelServiceAdapter, + ChannelAdapter, ClaimsIdentity, ) @@ -26,32 +30,60 @@ class A2ATurnContext(TurnContext): def __init__( self, - adapter_or_context: ChannelServiceAdapter | TurnContext, + adapter: ChannelAdapter, app: AgentApplication, - activity: Activity | None = None, - identity: ClaimsIdentity | None = None, + activity: Activity, + identity: ClaimsIdentity, + *, + request_context: RequestContext, + event_queue: EventQueue, + task_store: TaskStore, ) -> None: - """Initialize the A2A turn context. + """Initialize the A2A turn context with the given adapter, app, activity, and identity. - :param adapter_or_context: The channel service adapter or existing turn context. + :param adapter: The channel service adapter. :param app: The agent application instance. :param activity: The activity for the turn context. :param identity: The claims identity for the turn context. """ + super().__init__(adapter, activity, identity) - if isinstance(adapter_or_context, TurnContext): - super().__init__(adapter_or_context) - self._original = adapter_or_context - else: - super().__init__(adapter_or_context, activity, identity) - self._original = self - + self._original = self self._app = app - self._turn_state = self.turn_state self._activity.__class__ = A2AActivity self._a2a_activity = cast(A2AActivity, self._activity) + + self._services.set(RequestContext, request_context) + self._services.set(EventQueue, event_queue) + self._services.set(TaskStore, task_store) + self._client = A2AClient(self) + @staticmethod + def from_existing( + context: TurnContext, + app: AgentApplication, + ) -> A2ATurnContext: + """Initialize the A2A turn context. + + :param context: The existing turn context. + :param app: The agent application instance. + :return: An instance of A2ATurnContext. + """ + + obj = A2ATurnContext.__new__(A2ATurnContext) + + super(A2ATurnContext, obj).__init__(context) + + obj._original = context + obj._turn_state = context.turn_state + obj._app = app + obj._activity.__class__ = A2AActivity + obj._a2a_activity = cast(A2AActivity, obj._activity) + obj._client = A2AClient(obj) + + return obj + @property def client(self) -> A2AClient: """Get the A2A client associated with this turn context.""" @@ -60,16 +92,18 @@ def client(self) -> A2AClient: @property def responded(self) -> bool: """Check if the turn context has already sent a response.""" - return self._original.responded + return self._original._responded @responded.setter def responded(self, value: bool): """Set the responded status for the turn context.""" - self._original.responded = value + self._original._responded = value @property def streaming_response(self): """Get the streaming response associated with the turn context.""" + if self._original is self: + return super().streaming_response return self._original.streaming_response @property diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py index 0b03ccd6a..77bcad5e1 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/a2a_activity.py @@ -1,12 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -"""Teams-aware :class:`Activity` subclass exposing Teams channel data helpers.""" +"""A2A-aware :class:`Activity` subclass exposing A2A protocol helpers.""" from __future__ import annotations -import json - from typing import Iterable from uuid import uuid4 @@ -132,12 +130,14 @@ def _create_activity( ) for part in parts: - if part.text: - if not activity.text: + content_kind = part.WhichOneof("content") + + if content_kind == "text": + if activity.text is None: activity.text = part.text else: activity.text += part.text - elif part.url: + elif content_kind == "url": activity.attachments.append( Attachment( content_type=part.media_type, @@ -145,20 +145,20 @@ def _create_activity( name=part.filename, ) ) - elif part.raw: + elif content_kind == "raw": activity.attachments.append( Attachment( content_type=part.media_type, - content=part.raw, + content=bytes(part.raw), name=part.filename, ) ) - elif part.data: + elif content_kind == "data": activity.attachments.append( Attachment( - content_type="application/json", - content=json.dumps(MessageToDict(part.data)), - name="A2A DataPart", + content_type=part.media_type or "application/json", + content=MessageToDict(part.data), + name=part.filename, ) ) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py index 64cd69bcd..dd59bde15 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/activity/utils.py @@ -56,8 +56,6 @@ def activity_to_artifact( artifact.parts.append(Part(data=_to_protobuf_value(activity.value))) for attachment in activity.attachments or []: - if not attachment.content_url and not attachment.content: - continue part: Part if attachment.content_url: @@ -70,9 +68,16 @@ def activity_to_artifact( part = Part( data=_to_protobuf_value(attachment.content), media_type=attachment.content_type, + filename=attachment.name, + ) + elif isinstance(attachment.content, (bytes, bytearray, memoryview)): + part = Part( + raw=bytes(attachment.content), + media_type=attachment.content_type, + filename=attachment.name, ) else: - raise RuntimeError("Unsupported attachment content type.") + continue artifact.parts.append(part) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index 375fd579a..aaf2d5c86 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -22,6 +22,7 @@ logger = logging.getLogger(__name__) + def _create_jsonrpc_interface_routes( adapter: A2AAdapter, interface: AgentInterface, @@ -102,7 +103,9 @@ def add_a2a( agent_card_routes.extend(_agent_card_routes) else: unsupported_counter += 1 - logger.warning("Unsupported protocol binding: %s", interface.protocol_binding) + logger.warning( + "Unsupported protocol binding: %s", interface.protocol_binding + ) if unsupported_counter == len(interfaces): raise ValueError( diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py index dea130e80..00783e3c3 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py @@ -44,7 +44,7 @@ def wrap_a2a_route_handler( async def __func(context: TurnContext, state: _StateContra) -> None: if not isinstance(context, A2ATurnContext): - a2a_context = A2ATurnContext(context, app) + a2a_context = A2ATurnContext.from_existing(context, app) else: a2a_context = cast(A2ATurnContext, context) await handler(a2a_context, state) diff --git a/tests/hosting_a2a/activity/test_a2a_activity.py b/tests/hosting_a2a/activity/test_a2a_activity.py index c275d7d48..558bfc6d6 100644 --- a/tests/hosting_a2a/activity/test_a2a_activity.py +++ b/tests/hosting_a2a/activity/test_a2a_activity.py @@ -74,7 +74,11 @@ def test_from_message_maps_all_supported_part_types(): media_type="application/octet-stream", filename="data.bin", ), - Part(data=ParseDict({"answer": 42}, Value())), + Part( + data=ParseDict({"answer": 42}, Value()), + media_type="application/json", + filename="result.json", + ), ) activity = A2AActivity.from_message("request-1", None, message) @@ -90,9 +94,9 @@ def test_from_message_maps_all_supported_part_types(): assert raw_attachment.content == b"file contents" assert raw_attachment.content_type == "application/octet-stream" assert raw_attachment.name == "data.bin" - assert data_attachment.content == '{"answer": 42.0}' + assert data_attachment.content == {"answer": 42.0} assert data_attachment.content_type == "application/json" - assert data_attachment.name == "A2A DataPart" + assert data_attachment.name == "result.json" def test_supported_parts_round_trip_through_activity_artifact(): @@ -107,7 +111,11 @@ def test_supported_parts_round_trip_through_activity_artifact(): media_type="application/octet-stream", filename="data.bin", ), - Part(data=ParseDict({"answer": 42}, Value())), + Part( + data=ParseDict({"answer": 42}, Value()), + media_type="application/json", + filename="result.json", + ), ) activity = A2AActivity.from_message("request-1", None, message) @@ -118,6 +126,29 @@ def test_supported_parts_round_trip_through_activity_artifact(): assert artifact.parts[1].raw == b"file contents" assert artifact.parts[1].media_type == "application/octet-stream" assert MessageToDict(artifact.parts[2].data) == {"answer": 42.0} + assert artifact.parts[2].media_type == "application/json" + assert artifact.parts[2].filename == "result.json" + + +def test_empty_text_and_raw_parts_preserve_their_content_kind(): + message = _message( + Part(text=""), + Part( + raw=b"", + media_type="application/octet-stream", + filename="empty.bin", + ), + ) + + activity = A2AActivity.from_message("request-1", None, message) + artifact = activity.to_artifact("artifact-1") + + assert activity.text == "" + assert activity.attachments[0].content == b"" + assert artifact.parts[0].WhichOneof("content") == "text" + assert artifact.parts[0].text == "" + assert artifact.parts[1].WhichOneof("content") == "raw" + assert artifact.parts[1].raw == b"" def test_to_message_and_artifact_delegate_activity_content(): diff --git a/tests/hosting_a2a/activity/test_utils.py b/tests/hosting_a2a/activity/test_utils.py index 4e4ba7ad5..330c1ab6e 100644 --- a/tests/hosting_a2a/activity/test_utils.py +++ b/tests/hosting_a2a/activity/test_utils.py @@ -46,6 +46,7 @@ def test_activity_to_artifact_maps_text_value_and_attachments(): assert artifact.parts[2].filename == "image.png" assert MessageToDict(artifact.parts[3].data)["count"] == 2 assert artifact.parts[3].media_type == "application/json" + assert artifact.parts[3].filename == "result.json" def test_activity_to_artifact_adds_entities_and_excludes_stream_info(): @@ -75,7 +76,7 @@ def test_activity_to_artifact_can_exclude_entities(): assert list(artifact.parts) == [] -def test_activity_to_artifact_rejects_unsupported_attachment_content(): +def test_activity_to_artifact_skips_unsupported_attachment_content(): activity = Activity( type="message", attachments=[ @@ -86,8 +87,9 @@ def test_activity_to_artifact_rejects_unsupported_attachment_content(): ], ) - with pytest.raises(RuntimeError, match="Unsupported attachment content type"): - utils.activity_to_artifact(activity) + artifact = utils.activity_to_artifact(activity) + + assert list(artifact.parts) == [] def test_create_artifact_from_data_populates_artifact(): diff --git a/tests/hosting_a2a/test_a2a_agent_extension.py b/tests/hosting_a2a/test_a2a_agent_extension.py index 3433552e4..50eda6036 100644 --- a/tests/hosting_a2a/test_a2a_agent_extension.py +++ b/tests/hosting_a2a/test_a2a_agent_extension.py @@ -84,7 +84,7 @@ async def test_message_handler_reuses_existing_a2a_context(): extension = A2AAgentExtension(app) handler = AsyncMock() registered_handler = extension.message([])(handler) - context = A2ATurnContext(_turn_context(), app) + context = A2ATurnContext.from_existing(_turn_context(), app) state = object() await registered_handler(context, state) diff --git a/tests/hosting_a2a/test_a2a_turn_context.py b/tests/hosting_a2a/test_a2a_turn_context.py index 710ca35e5..018b77e46 100644 --- a/tests/hosting_a2a/test_a2a_turn_context.py +++ b/tests/hosting_a2a/test_a2a_turn_context.py @@ -51,7 +51,10 @@ def _original_context(): def test_wraps_existing_context_and_exposes_a2a_client(): original = _original_context() - context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) + context = A2ATurnContext.from_existing( + original, + MagicMock(spec=AgentApplication), + ) assert isinstance(context.activity, A2AActivity) assert context.activity.text == "hello" @@ -65,7 +68,10 @@ def test_wrapped_context_preserves_original_turn_state(): marker = object() original.turn_state["marker"] = marker - context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) + context = A2ATurnContext.from_existing( + original, + MagicMock(spec=AgentApplication), + ) assert context.turn_state is original.turn_state assert context.turn_state["marker"] is marker @@ -73,25 +79,41 @@ def test_wrapped_context_preserves_original_turn_state(): def test_can_be_constructed_directly_from_adapter_activity_and_identity(): adapter = _Adapter() - activity = Activity(type="message", text="hello") + activity = A2AActivity(type="message", text="hello") identity = ClaimsIdentity({"sub": "agent-1"}) + request_context = RequestContext( + ServerCallContext(), + task_id="task-1", + context_id="context-1", + ) + event_queue = EventQueueLegacy() + task_store = InMemoryTaskStore() context = A2ATurnContext( adapter, MagicMock(spec=AgentApplication), activity, identity, + request_context=request_context, + event_queue=event_queue, + task_store=task_store, ) assert context.adapter is adapter assert context.activity is activity assert isinstance(context.activity, A2AActivity) assert context.identity is identity + assert context.client.request_context is request_context + assert context.client.event_queue is event_queue + assert context.client.task_store is task_store def test_responded_property_is_forwarded_to_original_context(): original = _original_context() - context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) + context = A2ATurnContext.from_existing( + original, + MagicMock(spec=AgentApplication), + ) context.responded = True @@ -101,6 +123,9 @@ def test_responded_property_is_forwarded_to_original_context(): def test_streaming_response_is_forwarded_to_original_context(): original = _original_context() - context = A2ATurnContext(original, MagicMock(spec=AgentApplication)) + context = A2ATurnContext.from_existing( + original, + MagicMock(spec=AgentApplication), + ) assert context.streaming_response is original.streaming_response From 440114929c2a77bf14e0e02d882db17aa00273de Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 15:37:11 -0700 Subject: [PATCH 30/39] Fixing endOfConversation missing conversation field --- .../hosting/a2a/a2a_adapter.py | 2 + tests/hosting_a2a/integration/conftest.py | 66 +++- .../hosting_a2a/integration/test_protocol.py | 324 +++++++++++++++++- 3 files changed, 385 insertions(+), 7 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index 51333a6fd..0d0c6fd12 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -31,6 +31,7 @@ CallerIdConstants, Channels, ChannelAccount, + ConversationAccount, ChannelId, EndOfConversationCodes, InvokeResponse, @@ -173,6 +174,7 @@ async def cancel_agent_turn( type=ActivityTypes.end_of_conversation, code=EndOfConversationCodes.user_cancelled, channel_id=ChannelId(Channels.a2a), + conversation=ConversationAccount(id=context.task_id or ""), recipient=ChannelAccount(id="assistant", role=RoleTypes.agent), from_property=ChannelAccount(id=_DEFAULT_USER_ID, role=RoleTypes.user), ) diff --git a/tests/hosting_a2a/integration/conftest.py b/tests/hosting_a2a/integration/conftest.py index d00e953cb..f05723eb2 100644 --- a/tests/hosting_a2a/integration/conftest.py +++ b/tests/hosting_a2a/integration/conftest.py @@ -2,6 +2,8 @@ # Licensed under the MIT License. from collections.abc import AsyncIterator +import asyncio +from dataclasses import dataclass import httpx import pytest_asyncio @@ -29,8 +31,27 @@ STRUCTURED_RESULT_TRIGGER_TEXT = "structured-result" """Message text that completes with both text and structured result data.""" +LONG_RUNNING_TRIGGER_TEXT = "long-running" +"""Message text that leaves a task running until it is canceled.""" -def _create_agent_application() -> AgentApplication[TurnState]: +FAILURE_TRIGGER_TEXT = "fail" +"""Message text that raises from the SDK agent handler.""" + +PARTS_TRIGGER_TEXT = "echo-parts" +"""Message text that echoes incoming attachments through an outgoing message.""" + + +@dataclass +class A2ATestHarness: + client: httpx.AsyncClient + long_running_started: asyncio.Event + release_long_running: asyncio.Event + + +def _create_agent_application( + long_running_started: asyncio.Event, + release_long_running: asyncio.Event, +) -> AgentApplication[TurnState]: application = AgentApplication[TurnState]( options=ApplicationOptions(storage=MemoryStorage()), connection_manager=TestingConnectionManager(), @@ -67,6 +88,32 @@ async def on_message(context: TurnContext, _state: TurnState) -> None: ) ) return + elif text == LONG_RUNNING_TRIGGER_TEXT: + await context.send_activity( + Activity( + type=ActivityTypes.message, + text="Working", + ) + ) + long_running_started.set() + await release_long_running.wait() + await context.send_activity( + Activity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + ) + ) + return + elif text == FAILURE_TRIGGER_TEXT: + raise RuntimeError("Integration agent failed") + elif text == PARTS_TRIGGER_TEXT: + await context.send_activity( + Activity( + type=ActivityTypes.message, + text=text, + attachments=list(context.activity.attachments or []), + ) + ) else: await context.send_activity(f"Echo: {text}") await context.send_activity( @@ -80,11 +127,13 @@ async def on_message(context: TurnContext, _state: TurnState) -> None: @pytest_asyncio.fixture -async def a2a_client() -> AsyncIterator[httpx.AsyncClient]: +async def a2a_harness() -> AsyncIterator[A2ATestHarness]: """Create an unauthenticated application exposing JSON-RPC and REST A2A routes.""" app = FastAPI() - agent = _create_agent_application() + long_running_started = asyncio.Event() + release_long_running = asyncio.Event() + agent = _create_agent_application(long_running_started, release_long_running) adapter = A2AAdapter( agent, agent_card_name="Compatibility Agent", @@ -118,4 +167,13 @@ async def a2a_client() -> AsyncIterator[httpx.AsyncClient]: transport=httpx.ASGITransport(app=app), base_url="http://testserver", ) as client: - yield client + yield A2ATestHarness( + client=client, + long_running_started=long_running_started, + release_long_running=release_long_running, + ) + + +@pytest_asyncio.fixture +async def a2a_client(a2a_harness: A2ATestHarness) -> httpx.AsyncClient: + return a2a_harness.client diff --git a/tests/hosting_a2a/integration/test_protocol.py b/tests/hosting_a2a/integration/test_protocol.py index 134d00a62..62ebeea4f 100644 --- a/tests/hosting_a2a/integration/test_protocol.py +++ b/tests/hosting_a2a/integration/test_protocol.py @@ -3,12 +3,15 @@ from __future__ import annotations +import asyncio +import base64 import json import uuid import httpx import pytest -from google.protobuf.json_format import MessageToDict +from google.protobuf.json_format import MessageToDict, ParseDict +from google.protobuf.struct_pb2 import Value from a2a.types.a2a_pb2 import ( CancelTaskRequest, @@ -19,10 +22,15 @@ Part, Role, SendMessageRequest, + SubscribeToTaskRequest, ) from .conftest import ( + A2ATestHarness, + FAILURE_TRIGGER_TEXT, INPUT_REQUIRED_TRIGGER_TEXT, + LONG_RUNNING_TRIGGER_TEXT, + PARTS_TRIGGER_TEXT, STREAMING_TRIGGER_TEXT, STRUCTURED_RESULT_TRIGGER_TEXT, ) @@ -37,13 +45,14 @@ def _message( *, task_id: str = "", context_id: str = "", + parts: list[Part] | None = None, ) -> Message: return Message( role=Role.ROLE_USER, message_id=str(uuid.uuid4()), task_id=task_id, context_id=context_id, - parts=[Part(text=text)], + parts=parts if parts is not None else [Part(text=text)], ) @@ -52,9 +61,15 @@ def _send_request( *, task_id: str = "", context_id: str = "", + parts: list[Part] | None = None, ) -> SendMessageRequest: return SendMessageRequest( - message=_message(text, task_id=task_id, context_id=context_id) + message=_message( + text, + task_id=task_id, + context_id=context_id, + parts=parts, + ) ) @@ -123,6 +138,55 @@ async def _stream_rest( return events +async def _subscribe_rest( + client: httpx.AsyncClient, + task_id: str, +) -> list[dict]: + events: list[dict] = [] + async with client.stream( + "GET", + f"/rest/tasks/{task_id}:subscribe", + headers={"A2A-Version": "1.0", "Accept": "text/event-stream"}, + ) as response: + assert response.status_code == 200 + async for line in response.aiter_lines(): + if line.startswith("data:"): + events.append(json.loads(line[len("data:") :].strip())) + return events + + +async def _list_rest_tasks(client: httpx.AsyncClient) -> list[dict]: + response = await client.get( + "/rest/tasks", + headers={"A2A-Version": "1.0"}, + ) + assert response.status_code == 200 + return response.json()["tasks"] + + +async def _wait_for_task_state( + client: httpx.AsyncClient, + expected_state: str, +) -> dict: + async def wait() -> dict: + while True: + tasks = await _list_rest_tasks(client) + for task in tasks: + if task["status"]["state"] == expected_state: + return task + await asyncio.sleep(0) + + return await asyncio.wait_for(wait(), timeout=1) + + +def _history_text(task: dict) -> list[str]: + return [ + message["parts"][0]["text"] + for message in task["history"] + if message.get("parts") and "text" in message["parts"][0] + ] + + def _task_id(response: dict) -> str: return response["result"]["task"]["id"] @@ -250,6 +314,45 @@ async def test_jsonrpc_continues_input_required_task( ] +@pytest.mark.integration +@pytest.mark.asyncio +async def test_rest_continues_input_required_task( + a2a_client: httpx.AsyncClient, +) -> None: + first_response = await a2a_client.post( + "/rest/message:send", + json=MessageToDict(_send_request(INPUT_REQUIRED_TRIGGER_TEXT)), + headers={"A2A-Version": "1.0"}, + ) + assert first_response.status_code == 200 + first_task = first_response.json()["task"] + assert first_task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + + second_response = await a2a_client.post( + "/rest/message:send", + json=MessageToDict( + _send_request( + "additional input", + task_id=first_task["id"], + context_id=first_task["contextId"], + ) + ), + headers={"A2A-Version": "1.0"}, + ) + assert second_response.status_code == 200 + second_task = second_response.json()["task"] + + assert second_task["id"] == first_task["id"] + assert second_task["contextId"] == first_task["contextId"] + assert second_task["status"]["state"] == "TASK_STATE_COMPLETED" + assert _history_text(second_task) == [ + INPUT_REQUIRED_TRIGGER_TEXT, + "More information required", + "additional input", + "Echo: additional input", + ] + + @pytest.mark.integration @pytest.mark.asyncio async def test_jsonrpc_returns_structured_completion_artifact_and_status_message( @@ -273,6 +376,85 @@ async def test_jsonrpc_returns_structured_completion_artifact_and_status_message assert result_part["metadata"]["type"] == "object" +@pytest.mark.integration +@pytest.mark.asyncio +async def test_rest_returns_structured_completion_artifact_and_status_message( + a2a_client: httpx.AsyncClient, +) -> None: + response = await a2a_client.post( + "/rest/message:send", + json=MessageToDict(_send_request(STRUCTURED_RESULT_TRIGGER_TEXT)), + headers={"A2A-Version": "1.0"}, + ) + assert response.status_code == 200 + task = response.json()["task"] + + assert task["status"]["state"] == "TASK_STATE_COMPLETED" + assert task["status"]["message"]["parts"] == [ + {"text": "Completed with structured data"} + ] + result_part = task["artifacts"][0]["parts"][0] + assert result_part["data"] == {"answer": 42.0} + assert result_part["metadata"]["mimeType"] == "application/json" + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["jsonrpc", "rest"]) +async def test_message_parts_round_trip_through_protocol( + a2a_client: httpx.AsyncClient, + transport: str, +) -> None: + request = _send_request( + parts=[ + Part(text=PARTS_TRIGGER_TEXT), + Part( + url="https://example.com/image.png", + media_type="image/png", + filename="image.png", + ), + Part( + raw=b"file contents", + media_type="application/octet-stream", + filename="data.bin", + ), + Part( + data=ParseDict({"answer": 42}, Value()), + media_type="application/json", + filename="result.json", + ), + ] + ) + + if transport == "jsonrpc": + response = await _post_rpc(a2a_client, "SendMessage", request) + task = response["result"]["task"] + else: + response = await a2a_client.post( + "/rest/message:send", + json=MessageToDict(request), + headers={"A2A-Version": "1.0"}, + ) + assert response.status_code == 200 + task = response.json()["task"] + + parts = task["history"][1]["parts"] + assert parts[0] == {"text": PARTS_TRIGGER_TEXT} + assert parts[1] == { + "url": "https://example.com/image.png", + "mediaType": "image/png", + "filename": "image.png", + } + assert parts[2] == { + "raw": base64.b64encode(b"file contents").decode(), + "mediaType": "application/octet-stream", + "filename": "data.bin", + } + assert parts[3]["data"] == {"answer": 42.0} + assert parts[3]["mediaType"] == "application/json" + assert parts[3]["filename"] == "result.json" + + @pytest.mark.integration @pytest.mark.asyncio async def test_jsonrpc_cancel_missing_task_returns_protocol_error( @@ -289,6 +471,92 @@ async def test_jsonrpc_cancel_missing_task_returns_protocol_error( assert response["error"]["code"] != 0 +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["jsonrpc", "rest"]) +async def test_cancel_active_task_persists_canceled_state( + a2a_harness: A2ATestHarness, + transport: str, +) -> None: + client = a2a_harness.client + request = _send_request(LONG_RUNNING_TRIGGER_TEXT) + + if transport == "jsonrpc": + send_call = asyncio.create_task(_post_rpc(client, "SendMessage", request)) + else: + send_call = asyncio.create_task( + client.post( + "/rest/message:send", + json=MessageToDict(request), + headers={"A2A-Version": "1.0"}, + ) + ) + + await asyncio.wait_for(a2a_harness.long_running_started.wait(), timeout=1) + active_task = await _wait_for_task_state(client, "TASK_STATE_WORKING") + + if transport == "jsonrpc": + cancel_response = await _post_rpc( + client, + "CancelTask", + CancelTaskRequest(id=active_task["id"]), + request_id="cancel-active", + ) + else: + cancel_response = await client.post( + f"/rest/tasks/{active_task['id']}:cancel", + headers={"A2A-Version": "1.0"}, + ) + + a2a_harness.release_long_running.set() + send_result = await asyncio.wait_for(send_call, timeout=1) + + if transport == "jsonrpc": + canceled_task = cancel_response["result"] + send_task = send_result["result"]["task"] + else: + assert cancel_response.status_code == 200 + canceled_task = cancel_response.json() + assert send_result.status_code == 200 + send_task = send_result.json()["task"] + + assert canceled_task["status"]["state"] == "TASK_STATE_CANCELED" + assert send_task["status"]["state"] == "TASK_STATE_CANCELED" + + persisted = await client.get( + f"/rest/tasks/{active_task['id']}", + headers={"A2A-Version": "1.0"}, + ) + assert persisted.status_code == 200 + assert persisted.json()["status"]["state"] == "TASK_STATE_CANCELED" + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["jsonrpc", "rest"]) +async def test_agent_failure_returns_protocol_error_and_persists_failed_task( + a2a_client: httpx.AsyncClient, + transport: str, +) -> None: + request = _send_request(FAILURE_TRIGGER_TEXT) + + if transport == "jsonrpc": + response = await _post_rpc(a2a_client, "SendMessage", request) + assert "error" in response + else: + response = await a2a_client.post( + "/rest/message:send", + json=MessageToDict(request), + headers={"A2A-Version": "1.0"}, + ) + assert response.status_code == 500 + + tasks = await _list_rest_tasks(a2a_client) + assert len(tasks) == 1 + assert tasks[0]["status"]["state"] == "TASK_STATE_FAILED" + assert _history_text(tasks[0]) == [FAILURE_TRIGGER_TEXT] + + @pytest.mark.integration @pytest.mark.asyncio async def test_jsonrpc_rejects_unknown_method( @@ -385,6 +653,56 @@ async def test_rest_streaming_message_emits_ordered_task_events( assert final_update["status"]["state"] == "TASK_STATE_COMPLETED" +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["jsonrpc", "rest"]) +async def test_subscribe_to_active_task_streams_initial_and_completed_state( + a2a_harness: A2ATestHarness, + transport: str, +) -> None: + client = a2a_harness.client + send_call = asyncio.create_task( + client.post( + "/rest/message:send", + json=MessageToDict(_send_request(LONG_RUNNING_TRIGGER_TEXT)), + headers={"A2A-Version": "1.0"}, + ) + ) + + await asyncio.wait_for(a2a_harness.long_running_started.wait(), timeout=1) + active_task = await _wait_for_task_state(client, "TASK_STATE_WORKING") + + if transport == "jsonrpc": + subscribe_call = asyncio.create_task( + _stream_rpc( + client, + "SubscribeToTask", + SubscribeToTaskRequest(id=active_task["id"]), + request_id="subscribe-task", + ) + ) + else: + subscribe_call = asyncio.create_task(_subscribe_rest(client, active_task["id"])) + + await asyncio.sleep(0.05) + a2a_harness.release_long_running.set() + + events = await asyncio.wait_for(subscribe_call, timeout=1) + send_response = await asyncio.wait_for(send_call, timeout=1) + assert send_response.status_code == 200 + + if transport == "jsonrpc": + initial_task = events[0]["result"]["task"] + terminal_update = events[-1]["result"]["statusUpdate"] + else: + initial_task = events[0]["task"] + terminal_update = events[-1]["statusUpdate"] + + assert initial_task["id"] == active_task["id"] + assert initial_task["status"]["state"] == "TASK_STATE_WORKING" + assert terminal_update["status"]["state"] == "TASK_STATE_COMPLETED" + + @pytest.mark.integration @pytest.mark.asyncio async def test_jsonrpc_push_notification_config_is_explicitly_unsupported( From a832d84beed78010095540b34c77b2b271f86555 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 23 Sep 2026 15:38:33 -0700 Subject: [PATCH 31/39] add_a2a accepts AgentApplication rather than just Agent --- .../microsoft_agents/hosting/a2a/add_a2a.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index aaf2d5c86..49c479d29 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -10,7 +10,7 @@ from a2a.types import AgentInterface from a2a.utils.constants import TransportProtocol, AGENT_CARD_WELL_KNOWN_PATH -from microsoft_agents.hosting.core import Agent +from microsoft_agents.hosting.core import AgentApplication from .a2a_adapter import A2AAdapter from .server import ( @@ -62,7 +62,7 @@ def _create_http_interface_routes( def add_a2a( app: FastAPI, - agent: Agent, + agent: AgentApplication, adapter: A2AAdapter | None = None, *, use_jwt_middleware: bool = True, From 52321770257ffb6c1cf815f9030f0ceca8e76040 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 24 Sep 2026 10:56:23 -0700 Subject: [PATCH 32/39] Fixing BlobTaskStore list method --- .../microsoft_agents/hosting/a2a/blob_task_store.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py index b9e5419c0..b8259aa76 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py @@ -145,8 +145,12 @@ async def list( tasks: list[Task] = [] + kwargs = {} + if params.page_size > 0: + kwargs["results_per_page"] = params.page_size + items = self._container_client.list_blobs( - name_starts_with=_TASK_PREFIX, results_per_page=params.page_size + name_starts_with=_TASK_PREFIX, **kwargs ) iterator = items.by_page(params.page_token) @@ -155,14 +159,12 @@ async def list( if not first_page: return ListTasksResponse(tasks=[], next_page_token=None) - next_page_token = getattr(first_page, "continuation_token", None) - - # TODO -> async for blob in first_page: task = await self._download_task_blob(blob.name) if task and self._should_include_task(task, params): tasks.append(task) + next_page_token = getattr(iterator, "continuation_token", None) return ListTasksResponse(tasks=tasks, next_page_token=next_page_token) async def delete(self, task_id: str, context: ServerCallContext) -> None: From 25f6fd48b672accde7f5af408ba9944e62713035 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 24 Sep 2026 13:25:41 -0700 Subject: [PATCH 33/39] Adding BlobTaskStore tests --- .../integration/test_blob_protocol.py | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 tests/hosting_a2a/integration/test_blob_protocol.py diff --git a/tests/hosting_a2a/integration/test_blob_protocol.py b/tests/hosting_a2a/integration/test_blob_protocol.py new file mode 100644 index 000000000..e78e9fc4c --- /dev/null +++ b/tests/hosting_a2a/integration/test_blob_protocol.py @@ -0,0 +1,207 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import os +import uuid +from contextlib import asynccontextmanager + +import httpx +import pytest +from a2a.types import ( + AgentInterface, + ListTasksRequest, + Message, + Part, + Role, + SendMessageRequest, +) +from a2a.utils.constants import TransportProtocol +from azure.core.exceptions import ResourceNotFoundError +from azure.identity.aio import DefaultAzureCredential +from azure.storage.blob.aio import BlobServiceClient, ContainerClient +from dotenv import load_dotenv +from fastapi import FastAPI +from google.protobuf.json_format import MessageToDict + +from microsoft_agents.hosting.a2a import A2AAdapter, add_a2a +from microsoft_agents.hosting.a2a.blob_task_store import BlobTaskStore + +from .conftest import _create_agent_application + +pytestmark = [ + pytest.mark.blob, + pytest.mark.integration, + pytest.mark.filterwarnings( + "ignore:label\\(\\) is deprecated\\. Use is_required\\(\\) or is_repeated\\(\\) instead\\.:DeprecationWarning" + ), +] + + +def _send_request(text: str) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id=str(uuid.uuid4()), + parts=[Part(text=text)], + ) + ) + + +def _rpc_request(method: str, params: object, request_id: str) -> dict: + return { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": MessageToDict(params), + } + + +async def _post_rpc( + client: httpx.AsyncClient, + method: str, + params: object, + request_id: str, +) -> dict: + response = await client.post( + "/rpc", + json=_rpc_request(method, params, request_id), + headers={"A2A-Version": "1.0"}, + ) + assert response.status_code == 200 + return response.json() + + +@asynccontextmanager +async def _blob_container(): + load_dotenv() + connection_string = os.environ.get("TEST_BLOB_STORAGE_CONNECTION_STRING") + credential = None + + if connection_string: + service_client = BlobServiceClient.from_connection_string(connection_string) + else: + account_url = os.environ.get("TEST_BLOB_STORAGE_ACCOUNT_URL") + if not account_url: + pytest.skip( + "Set TEST_BLOB_STORAGE_CONNECTION_STRING or " + "TEST_BLOB_STORAGE_ACCOUNT_URL" + ) + credential = DefaultAzureCredential() + service_client = BlobServiceClient(account_url, credential=credential) + + container_name = f"asdka2aprotocol{uuid.uuid4().hex}" + container_client = service_client.get_container_client(container_name) + + try: + yield container_client + finally: + try: + await container_client.delete_container() + except ResourceNotFoundError: + pass + await container_client.close() + await service_client.close() + if credential: + await credential.close() + + +@asynccontextmanager +async def _blob_backed_a2a_client(container_client: ContainerClient): + app = FastAPI() + agent = _create_agent_application(asyncio.Event(), asyncio.Event()) + adapter = A2AAdapter( + agent, + agent_interfaces=[ + AgentInterface( + url="/rpc", + protocol_binding=TransportProtocol.JSONRPC, + ), + AgentInterface( + url="/rest", + protocol_binding=TransportProtocol.HTTP_JSON, + ), + ], + task_store=BlobTaskStore(container_client), + ) + add_a2a(app, agent, adapter=adapter, use_jwt_middleware=False) + + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + yield client + finally: + await adapter.a2a_request_handler.aclose() + + +@pytest.mark.asyncio +async def test_task_persists_across_app_restart_and_protocols(): + async with _blob_container() as container_client: + async with _blob_backed_a2a_client(container_client) as client: + sent = await _post_rpc( + client, + "SendMessage", + _send_request("persisted"), + "send-task", + ) + task = sent["result"]["task"] + + async with _blob_backed_a2a_client(container_client) as restarted_client: + get_response = await restarted_client.get( + f"/rest/tasks/{task['id']}", + headers={"A2A-Version": "1.0"}, + ) + list_response = await restarted_client.get( + "/rest/tasks", + headers={"A2A-Version": "1.0"}, + ) + + assert get_response.status_code == 200 + assert get_response.json() == task + assert list_response.status_code == 200 + assert [item["id"] for item in list_response.json()["tasks"]] == [task["id"]] + + +@pytest.mark.asyncio +async def test_jsonrpc_list_tasks_uses_blob_continuation_tokens(): + async with _blob_container() as container_client: + async with _blob_backed_a2a_client(container_client) as client: + task_ids = set() + for index in range(3): + sent = await _post_rpc( + client, + "SendMessage", + _send_request(f"task-{index}"), + f"send-{index}", + ) + task_ids.add(sent["result"]["task"]["id"]) + + first_page = await _post_rpc( + client, + "ListTasks", + ListTasksRequest(page_size=2), + "list-first-page", + ) + first_result = first_page["result"] + second_page = await _post_rpc( + client, + "ListTasks", + ListTasksRequest( + page_size=2, + page_token=first_result["nextPageToken"], + ), + "list-second-page", + ) + second_result = second_page["result"] + + first_ids = {task["id"] for task in first_result["tasks"]} + second_ids = {task["id"] for task in second_result["tasks"]} + + assert len(first_ids) == 2 + assert first_result["nextPageToken"] + assert len(second_ids) == 1 + assert second_result.get("nextPageToken", "") == "" + assert first_ids.isdisjoint(second_ids) + assert first_ids | second_ids == task_ids \ No newline at end of file From f3aa606184091b5a7e2b734e2e9b524f352e619e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 24 Sep 2026 13:34:58 -0700 Subject: [PATCH 34/39] Another commit --- test_samples/a2a/agent/agent.py | 5 ++++- test_samples/a2a/agent/setup.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 test_samples/a2a/agent/setup.py diff --git a/test_samples/a2a/agent/agent.py b/test_samples/a2a/agent/agent.py index 7b49333ce..8a18bfba5 100644 --- a/test_samples/a2a/agent/agent.py +++ b/test_samples/a2a/agent/agent.py @@ -7,8 +7,11 @@ EndOfConversationCodes, TurnContextProtocol, ) -from microsoft_agents.hosting.core import ActivityHandler +from microsoft_agents.hosting.core import ( + AgentApplication, +) +app = AgentApplication() class EchoAgent(ActivityHandler): """A small SDK agent that echoes each A2A message.""" diff --git a/test_samples/a2a/agent/setup.py b/test_samples/a2a/agent/setup.py new file mode 100644 index 000000000..27d5b093a --- /dev/null +++ b/test_samples/a2a/agent/setup.py @@ -0,0 +1,22 @@ +import os + +from dotenv import load_dotenv + +from microsoft_agents.activity import ( + load_configuration_from_env, +) +from microsoft_agents.hosting.core import ( + Authorization, + MemoryStorage, +) +from microsoft_agents.authentication.msal import MsalConnectionManager + +load_dotenv() + +CONFIG = load_configuration_from_env(os.environ) + +STORAGE = MemoryStorage() +CONNECTIONS = MsalConnectionManager(**CONFIG) +ADAPTER = A2AAdapter() +AUTHORIZATION = Authorization(STORAGE, CONNECTIONS, **CONFIG) + From 0188cd8068c3e4bfa846cf7152764c355760c5b5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 29 Sep 2026 12:04:09 -0700 Subject: [PATCH 35/39] Part to attachment conversion fix --- .../microsoft_agents/activity/attachment.py | 9 ++- .../request_handling/a2a_request_handler.py | 30 +++++++++- .../hosting_a2a/activity/test_a2a_activity.py | 21 +++++++ .../hosting_a2a/integration/test_protocol.py | 39 ++++++++++++ .../request_handling/test_request_handling.py | 60 +++++++++++++++++++ 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/attachment.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/attachment.py index af702f5af..6d31ca3c0 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/attachment.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/attachment.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from .agents_model import AgentsModel -from ._type_aliases import NonEmptyString class Attachment(AgentsModel): @@ -20,8 +19,8 @@ class Attachment(AgentsModel): :type thumbnail_url: str """ - content_type: NonEmptyString - content_url: NonEmptyString = None + content_type: str + content_url: str | None = None content: object = None - name: NonEmptyString = None - thumbnail_url: NonEmptyString = None + name: str | None = None + thumbnail_url: str | None = None diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py index 89501aa93..755bcaa9e 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py @@ -1,10 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from a2a.types import AgentCard +from a2a.types import AgentCard, SubscribeToTaskRequest from a2a.server.tasks import TaskStore from a2a.server.request_handlers import DefaultRequestHandlerV2 +from a2a.utils.errors import ( + TaskNotFoundError, + UnsupportedOperationError, +) +from a2a.server.context import ServerCallContext +from a2a.server.agent_execution.active_task import TERMINAL_TASK_STATES from .a2a_http_adapter import A2AHttpAdapter @@ -12,6 +18,7 @@ class A2ARequestHandler(DefaultRequestHandlerV2): + """Request handler for A2A interactions.""" def __init__( self, @@ -28,3 +35,24 @@ def update_agent_card(self, agent_card: AgentCard) -> None: :param agent_card: The AgentCard instance to update the handler with. """ self._agent_card = agent_card + + async def on_subscribe_to_task(self, params: SubscribeToTaskRequest, context: ServerCallContext): + """Handle subscription to a task. + + :param params: The parameters for the subscription request. + :param context: The context of the request. + :raises TaskNotFoundError: If the task does not exist. + :raises UnsupportedOperationError: If the task is in a terminal state. + :yield: Events related to the task subscription. + """ + task = await self.task_store.get(params.id, context) + if task is None: + raise TaskNotFoundError() + + if task.status.state in TERMINAL_TASK_STATES: + raise UnsupportedOperationError( + "Cannot subscribe to a terminal task." + ) + + async for event in super().on_subscribe_to_task(params, context): + yield event \ No newline at end of file diff --git a/tests/hosting_a2a/activity/test_a2a_activity.py b/tests/hosting_a2a/activity/test_a2a_activity.py index 558bfc6d6..3b6015696 100644 --- a/tests/hosting_a2a/activity/test_a2a_activity.py +++ b/tests/hosting_a2a/activity/test_a2a_activity.py @@ -99,6 +99,27 @@ def test_from_message_maps_all_supported_part_types(): assert data_attachment.name == "result.json" +def test_from_message_accepts_parts_without_optional_filenames(): + message = _message( + Part( + url="https://example.com/image.png", + media_type="image/png", + ), + Part( + raw=b"file contents", + media_type="application/octet-stream", + ), + Part( + data=ParseDict({"answer": 42}, Value()), + media_type="application/json", + ), + ) + + activity = A2AActivity.from_message("request-1", None, message) + + assert [attachment.name for attachment in activity.attachments] == ["", "", ""] + + def test_supported_parts_round_trip_through_activity_artifact(): message = _message( Part( diff --git a/tests/hosting_a2a/integration/test_protocol.py b/tests/hosting_a2a/integration/test_protocol.py index 62ebeea4f..a3c2eb370 100644 --- a/tests/hosting_a2a/integration/test_protocol.py +++ b/tests/hosting_a2a/integration/test_protocol.py @@ -703,6 +703,45 @@ async def test_subscribe_to_active_task_streams_initial_and_completed_state( assert terminal_update["status"]["state"] == "TASK_STATE_COMPLETED" +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["jsonrpc", "rest"]) +async def test_subscribe_to_completed_task_returns_unsupported_operation( + a2a_client: httpx.AsyncClient, + transport: str, +) -> None: + send_response = await _post_rpc( + a2a_client, + "SendMessage", + _send_request("complete-task"), + ) + task_id = send_response["result"]["task"]["id"] + + if transport == "jsonrpc": + response = await _post_rpc( + a2a_client, + "SubscribeToTask", + SubscribeToTaskRequest(id=task_id), + request_id="subscribe-completed", + ) + + assert response["error"]["code"] == -32004 + assert response["error"]["data"][0]["reason"] == "UNSUPPORTED_OPERATION" + else: + response = await a2a_client.get( + f"/rest/tasks/{task_id}:subscribe", + headers={ + "A2A-Version": "1.0", + "Accept": "text/event-stream", + }, + ) + + assert response.status_code == 400 + assert response.json()["error"]["details"][0]["reason"] == ( + "UNSUPPORTED_OPERATION" + ) + + @pytest.mark.integration @pytest.mark.asyncio async def test_jsonrpc_push_notification_config_is_explicitly_unsupported( diff --git a/tests/hosting_a2a/request_handling/test_request_handling.py b/tests/hosting_a2a/request_handling/test_request_handling.py index f742010f7..1f3927062 100644 --- a/tests/hosting_a2a/request_handling/test_request_handling.py +++ b/tests/hosting_a2a/request_handling/test_request_handling.py @@ -20,6 +20,7 @@ Part, Role, SendMessageRequest, + SubscribeToTaskRequest, Task, TaskState, TaskStatus, @@ -27,6 +28,7 @@ ) from a2a.utils.errors import ( ExtendedAgentCardNotConfiguredError, + TaskNotFoundError, UnsupportedOperationError, ) @@ -431,6 +433,64 @@ async def test_request_handler_persists_failed_task_when_adapter_raises(): await handler.aclose() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "terminal_state", + [ + TaskState.TASK_STATE_COMPLETED, + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_CANCELED, + TaskState.TASK_STATE_REJECTED, + ], +) +async def test_request_handler_rejects_subscription_to_terminal_task( + terminal_state, +): + task_store = InMemoryTaskStore() + handler = A2ARequestHandler(_CompletingAdapter(), task_store, _agent_card()) + call_context = ServerCallContext() + task = Task( + id="task-1", + context_id="context-1", + status=TaskStatus(state=terminal_state), + ) + await task_store.save(task, call_context) + + try: + events = handler.on_subscribe_to_task( + SubscribeToTaskRequest(id=task.id), + call_context, + ) + + with pytest.raises( + UnsupportedOperationError, + match="Cannot subscribe to a terminal task", + ): + await anext(events) + finally: + await handler.aclose() + + +@pytest.mark.asyncio +async def test_request_handler_subscription_preserves_task_not_found_error(): + handler = A2ARequestHandler( + _CompletingAdapter(), + InMemoryTaskStore(), + _agent_card(), + ) + + try: + events = handler.on_subscribe_to_task( + SubscribeToTaskRequest(id="missing-task"), + ServerCallContext(), + ) + + with pytest.raises(TaskNotFoundError): + await anext(events) + finally: + await handler.aclose() + + @pytest.mark.asyncio async def test_request_handler_update_agent_card_changes_extended_card_capability(): original_card = _agent_card() From d0a8e3f4c7a16630c536d6cf44b0b3f8c928bc8a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 29 Sep 2026 14:21:55 -0700 Subject: [PATCH 36/39] Improvement to Agent Card routing logic --- dev/a2a/.gitignore | 3 + dev/a2a/README.md | 52 ++++ dev/a2a/agent.py | 236 ++++++++++++++++++ dev/a2a/run_tck.ps1 | 155 ++++++++++++ .../hosting/a2a/a2a_adapter.py | 4 + .../request_handling/a2a_request_handler.py | 10 +- .../hosting/a2a/server/routes.py | 22 +- scripts/dev_setup.ps1 | 1 + scripts/dev_setup.sh | 1 + .../integration/test_blob_protocol.py | 2 +- tests/hosting_a2a/server/test_routes.py | 4 + 11 files changed, 482 insertions(+), 8 deletions(-) create mode 100644 dev/a2a/.gitignore create mode 100644 dev/a2a/README.md create mode 100644 dev/a2a/agent.py create mode 100644 dev/a2a/run_tck.ps1 diff --git a/dev/a2a/.gitignore b/dev/a2a/.gitignore new file mode 100644 index 000000000..3a82ddea6 --- /dev/null +++ b/dev/a2a/.gitignore @@ -0,0 +1,3 @@ +.tck/ +*.log + diff --git a/dev/a2a/README.md b/dev/a2a/README.md new file mode 100644 index 000000000..2280bbaf8 --- /dev/null +++ b/dev/a2a/README.md @@ -0,0 +1,52 @@ +# A2A TCK development harness + +This directory contains a local A2A agent and a PowerShell runner for the +official [A2A Technology Compatibility Kit][tck]. + +## Prerequisites + +From the repository root, prepare the development environment: + +```powershell +.\scripts\dev_setup.ps1 +``` + +Install [uv](https://docs.astral.sh/uv/) and ensure `git` is available on +`PATH`. + +## Run the TCK + +From the repository root: + +```powershell +.\dev\a2a\run_tck.ps1 +``` + +The script: + +1. Clones or updates the TCK under `dev\a2a\.tck`. +2. Creates the TCK virtual environment and installs its dependencies. +3. Starts the local agent on `http://127.0.0.1:41241`. +4. Runs the TCK against the JSON-RPC and HTTP+JSON interfaces. +5. Stops the local agent. + +By default, the complete suite runs for both supported transports. Examples: + +```powershell +# Run only MUST requirements over JSON-RPC. +.\dev\a2a\run_tck.ps1 -Transport jsonrpc -Level must + +# Run only HTTP+JSON against a specific TCK revision. +.\dev\a2a\run_tck.ps1 -Transport http_json -TckRevision main + +# Forward additional arguments to pytest through the TCK. +.\dev\a2a\run_tck.ps1 -PytestArgs "-x", "--pdb" +``` + +TCK reports are written under `dev\a2a\.tck\reports`. + +The test agent intentionally disables JWT authentication. It is only intended +for local protocol compatibility testing. + +[tck]: https://github.com/a2aproject/a2a-tck + diff --git a/dev/a2a/agent.py b/dev/a2a/agent.py new file mode 100644 index 000000000..ddd2ce572 --- /dev/null +++ b/dev/a2a/agent.py @@ -0,0 +1,236 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +import argparse +import asyncio +from uuid import uuid4 + +import uvicorn +from a2a.types import AgentInterface, AgentSkill +from a2a.utils.constants import TransportProtocol +from fastapi import FastAPI +from starlette.routing import Route + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + Attachment, + EndOfConversationCodes, + InputHints, + StreamInfo, +) +from microsoft_agents.hosting.a2a import A2AAdapter, add_a2a +from microsoft_agents.hosting.core import ( + AgentApplication, + AgentAuthConfiguration, + AnonymousTokenProvider, + ApplicationOptions, + ConnectionManager, + MemoryStorage, + TurnContext, + TurnState, +) + + +def create_agent() -> AgentApplication[TurnState]: + connection_manager = ConnectionManager( + provider_factory=lambda _: AnonymousTokenProvider(), + connections_configurations={ + "SERVICE_CONNECTION": AgentAuthConfiguration( + anonymous_allowed=True, + ) + }, + ) + application = AgentApplication[TurnState]( + options=ApplicationOptions(storage=MemoryStorage()), + connection_manager=connection_manager, + ) + + @application.activity("message") + async def on_message(context: TurnContext, _state: TurnState) -> None: + message_id = getattr(context.activity.channel_data, "message_id", "") + + async def send_artifact( + *, + text: str | None = None, + value: dict | None = None, + attachment: Attachment | None = None, + ) -> None: + await context.send_activity( + Activity( + type=ActivityTypes.message, + text=text, + value=value, + attachments=[attachment] if attachment else [], + entities=[ + StreamInfo( + stream_id=str(uuid4()), + stream_type="content", + stream_sequence=1, + ) + ], + ) + ) + + if message_id.startswith("test-resubscribe-message-id"): + await context.send_activity("Working") + await asyncio.sleep(4) + elif message_id.startswith("tck-input-required"): + await context.send_activity( + Activity( + type=ActivityTypes.message, + input_hint=InputHints.expecting_input, + ) + ) + return + elif message_id.startswith("tck-message-response"): + await context.send_activity("Direct message response") + return + elif message_id.startswith("tck-artifact-file-url"): + await send_artifact( + attachment=Attachment( + content_type="text/plain", + content_url="https://example.com/output.txt", + name="output.txt", + ) + ) + elif message_id.startswith("tck-artifact-file"): + await send_artifact( + attachment=Attachment( + content_type="text/plain", + content=b"tck", + name="output.txt", + ) + ) + elif message_id.startswith("tck-artifact-data"): + await send_artifact(value={"key": "value", "count": 42}) + elif message_id.startswith("tck-artifact-text"): + await send_artifact(text="Generated text content") + elif message_id.startswith("tck-stream-artifact-chunked"): + response = context.streaming_response + response.queue_text_chunk("chunk-1 ") + response.queue_text_chunk("chunk-2") + await response.end_stream() + return + elif message_id.startswith("tck-stream-artifact-file"): + await send_artifact( + attachment=Attachment( + content_type="text/plain", + content=b"tck", + name="output.txt", + ) + ) + elif message_id.startswith("tck-stream-artifact-text"): + response = context.streaming_response + response.queue_text_chunk("Streamed text content") + await response.end_stream() + return + elif message_id.startswith("tck-stream-ordering-001"): + response = context.streaming_response + response.queue_informative_update("Working") + response.queue_text_chunk("Ordered output") + await response.end_stream() + return + elif message_id.startswith("tck-stream-001"): + response = context.streaming_response + response.queue_text_chunk("Stream hello from TCK") + await response.end_stream() + return + elif message_id.startswith("tck-stream-002"): + pass + elif message_id.startswith("tck-stream-003"): + response = context.streaming_response + response.queue_informative_update("Working") + response.queue_text_chunk("Stream task lifecycle") + await response.end_stream() + return + else: + await context.send_activity("Hello from TCK") + + await context.send_activity( + Activity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + ) + ) + + return application + + +def create_app() -> FastAPI: + agent = create_agent() + adapter = A2AAdapter( + agent, + agent_card_name="Microsoft Agents SDK TCK Agent", + agent_card_description="A deterministic agent for A2A TCK validation.", + agent_card_version="1.0.0", + agent_interfaces=[ + AgentInterface( + url="/rpc", + protocol_binding=TransportProtocol.JSONRPC, + ), + AgentInterface( + url="/rest", + protocol_binding=TransportProtocol.HTTP_JSON, + ), + ], + skills=[ + AgentSkill( + id="echo", + name="Echo", + description="Echoes incoming text.", + tags=["test", "echo"], + examples=["Hello"], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ], + ) + + app = FastAPI(title="Microsoft Agents SDK A2A TCK Agent", version="1.0.0") + add_a2a( + app, + agent, + adapter=adapter, + use_jwt_middleware=False, + ) + + rpc_route = next( + route + for route in app.router.routes + if isinstance(route, Route) and route.path == "/rpc" + ) + mount_index = next( + index + for index, route in enumerate(app.router.routes) + if route.path == "/{tenant}" + ) + app.router.routes.insert( + mount_index, + Route( + path="/rpc/", + endpoint=rpc_route.endpoint, + methods=["POST"], + ), + ) + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + return app + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the local A2A TCK agent.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=41241, type=int) + args = parser.parse_args() + + uvicorn.run(create_app(), host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/dev/a2a/run_tck.ps1 b/dev/a2a/run_tck.ps1 new file mode 100644 index 000000000..c366e99f9 --- /dev/null +++ b/dev/a2a/run_tck.ps1 @@ -0,0 +1,155 @@ +param( + [ValidateSet("jsonrpc", "http_json", "all")] + [string]$Transport = "all", + + [ValidateSet("must", "should", "may", "all")] + [string]$Level = "all", + + [string]$TckRevision = "main", + + [string]$HostAddress = "127.0.0.1", + + [int]$Port = 41241, + + [string[]]$PytestArgs = @() +) + +$ErrorActionPreference = "Stop" + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$repositoryPython = Join-Path $repositoryRoot "venv\Scripts\python.exe" +$agentPath = Join-Path $PSScriptRoot "agent.py" +$tckPath = Join-Path $PSScriptRoot ".tck" +$tckPython = Join-Path $tckPath ".venv\Scripts\python.exe" +$stdoutLog = Join-Path $PSScriptRoot "agent.stdout.log" +$stderrLog = Join-Path $PSScriptRoot "agent.stderr.log" +$sutHost = "http://${HostAddress}:$Port/rpc" + +if (-not (Test-Path $repositoryPython)) { + throw "Repository virtual environment not found. Run .\scripts\dev_setup.ps1 first." +} + +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + throw "git was not found on PATH." +} + +if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw "uv was not found on PATH. Install it from https://docs.astral.sh/uv/." +} + +if (-not (Test-Path (Join-Path $tckPath ".git"))) { + git clone https://github.com/a2aproject/a2a-tck.git $tckPath + if ($LASTEXITCODE -ne 0) { + throw "Failed to clone the A2A TCK repository." + } +} + +git -C $tckPath fetch origin $TckRevision +if ($LASTEXITCODE -ne 0) { + throw "Failed to fetch TCK revision '$TckRevision'." +} + +git -C $tckPath checkout --detach FETCH_HEAD +if ($LASTEXITCODE -ne 0) { + throw "Failed to check out TCK revision '$TckRevision'." +} + +if (-not (Test-Path $tckPython)) { + uv venv (Join-Path $tckPath ".venv") --python 3.11 + if ($LASTEXITCODE -ne 0) { + throw "Failed to create the TCK virtual environment." + } +} + +& $tckPython -m ensurepip --upgrade +if ($LASTEXITCODE -ne 0) { + throw "Failed to bootstrap pip in the TCK virtual environment." +} + +& $tckPython -m pip install -e $tckPath +if ($LASTEXITCODE -ne 0) { + throw "Failed to install the TCK." +} + +Remove-Item $stdoutLog, $stderrLog -ErrorAction SilentlyContinue + +$agentProcess = $null +$exitCode = 1 + +try { + $agentProcess = Start-Process ` + -FilePath $repositoryPython ` + -ArgumentList @($agentPath, "--host", $HostAddress, "--port", $Port) ` + -WorkingDirectory $repositoryRoot ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog ` + -PassThru + + $healthUrl = "http://${HostAddress}:$Port/health" + $deadline = (Get-Date).AddSeconds(30) + $healthy = $false + + while ((Get-Date) -lt $deadline) { + if ($agentProcess.HasExited) { + break + } + + try { + $response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing + if ($response.StatusCode -eq 200) { + $healthy = $true + break + } + } + catch { + Start-Sleep -Milliseconds 250 + } + } + + if (-not $healthy) { + if (Test-Path $stderrLog) { + Get-Content $stderrLog + } + throw "The A2A test agent did not become healthy at $healthUrl." + } + + $tckArguments = @( + (Join-Path $tckPath "run_tck.py"), + "--sut-host", + $sutHost, + "-v" + ) + + if ($Transport -eq "all") { + $tckArguments += @("--transport", "jsonrpc,http_json") + } + else { + $tckArguments += @("--transport", $Transport) + } + + if ($Level -ne "all") { + $tckArguments += @("--level", $Level) + } + + if ($PytestArgs.Count -gt 0) { + $tckArguments += "--" + $tckArguments += $PytestArgs + } + + Push-Location $tckPath + try { + & $tckPython @tckArguments + $exitCode = $LASTEXITCODE + } + finally { + Pop-Location + } +} +finally { + if ($null -ne $agentProcess -and -not $agentProcess.HasExited) { + Stop-Process -Id $agentProcess.Id + $agentProcess.WaitForExit() + } +} + +exit $exitCode diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index 0d0c6fd12..aa271227c 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -7,6 +7,7 @@ from urllib.parse import urlsplit from uuid import uuid4 + from a2a.types import ( AgentCard, AgentInterface, @@ -25,6 +26,7 @@ from a2a.server.tasks import TaskStore, InMemoryTaskStore from a2a.utils.constants import TransportProtocol + from microsoft_agents.activity import ( Activity, ActivityTypes, @@ -54,6 +56,7 @@ from microsoft_agents.hosting.core.http._http_request_protocol import ( HttpRequestProtocol, ) +from microsoft_agents.hosting.core.http._http_response import HttpResponse from .request_handling import ( A2AHttpAdapter, @@ -92,6 +95,7 @@ def __init__( :param agent_interfaces: The list of agent interfaces associated with the adapter. :param skills: The list of skills associated with the adapter. :param task_store: Optional task store for managing tasks. If not provided, an in-memory task store will be used. + :param agent_card_cache_max_age: The maximum age (in seconds) for caching the agent card. Default is 3600 seconds (1 hour). """ self.middleware_set = MiddlewareSet() diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py index 755bcaa9e..77a59bb71 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/request_handling/a2a_request_handler.py @@ -36,7 +36,9 @@ def update_agent_card(self, agent_card: AgentCard) -> None: """ self._agent_card = agent_card - async def on_subscribe_to_task(self, params: SubscribeToTaskRequest, context: ServerCallContext): + async def on_subscribe_to_task( + self, params: SubscribeToTaskRequest, context: ServerCallContext + ): """Handle subscription to a task. :param params: The parameters for the subscription request. @@ -50,9 +52,7 @@ async def on_subscribe_to_task(self, params: SubscribeToTaskRequest, context: Se raise TaskNotFoundError() if task.status.state in TERMINAL_TASK_STATES: - raise UnsupportedOperationError( - "Cannot subscribe to a terminal task." - ) + raise UnsupportedOperationError("Cannot subscribe to a terminal task.") async for event in super().on_subscribe_to_task(params, context): - yield event \ No newline at end of file + yield event diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 7b8c0661d..f0f33671c 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import hashlib + +from datetime import datetime, timezone +from email.utils import format_datetime from typing import Awaitable, Callable, Sequence from a2a.server.routes import ( @@ -27,6 +31,8 @@ from .sdk_server_call_context_builder import SDKServerCallContextBuilder +_AGENT_CARD_CACHE_CONTROL_TEMPLATE = "public, max-age={}" + def create_jsonrpc_routes( request_handler: RequestHandler, @@ -60,17 +66,19 @@ def create_rest_routes( :param enable_v0_3_compat: Whether to enable compatibility with version 0.3. :return: A list of BaseRoute objects representing the REST routes. """ - return _create_rest_routes( + routes = _create_rest_routes( request_handler, context_builder=SDKServerCallContextBuilder(), enable_v0_3_compat=enable_v0_3_compat, path_prefix=path_prefix, ) + return routes def create_agent_card_routes( get_agent_card: Callable[[HttpRequestProtocol, str], Awaitable[AgentCard]], card_url: str = AGENT_CARD_WELL_KNOWN_PATH, + cache_max_age: int = 3600, # seconds, so 1 hour ) -> list[BaseRoute]: """Create routes for serving the agent card. @@ -88,10 +96,20 @@ def create_agent_card_routes( # not found pass + last_modified = format_datetime(datetime.now(timezone.utc), usegmt=True) + async def _get_agent_card(request: Request) -> Response: """Retruns the public AgentCard describing this agent's capabilities, supported transports, and skills.""" card = await get_agent_card(FastApiRequestAdapter(request), prefix) - return JSONResponse(agent_card_to_dict(card)) + response = JSONResponse(agent_card_to_dict(card)) + etag = f'"{hashlib.sha256(response.body).hexdigest()}"' + headers = { + "Cache-Control": _AGENT_CARD_CACHE_CONTROL_TEMPLATE.format(cache_max_age), + "ETag": etag, + "Last-Modified": last_modified, + } + response.headers.update(headers) + return response return [Route(path=card_url, endpoint=_get_agent_card, methods=["GET"])] diff --git a/scripts/dev_setup.ps1 b/scripts/dev_setup.ps1 index 2b3f8d35d..109db212d 100644 --- a/scripts/dev_setup.ps1 +++ b/scripts/dev_setup.ps1 @@ -6,6 +6,7 @@ pip install -e ./libraries/microsoft-agents-activity/ --config-settings editable pip install -e ./libraries/microsoft-agents-authentication-msal/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-authentication-entra-auth-sidecar/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-copilotstudio-client/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-hosting-a2a/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-hosting-aiohttp/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-hosting-core/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-hosting-teams/ --config-settings editable_mode=compat diff --git a/scripts/dev_setup.sh b/scripts/dev_setup.sh index b013fced4..ac6c42182 100644 --- a/scripts/dev_setup.sh +++ b/scripts/dev_setup.sh @@ -6,6 +6,7 @@ pip install -e ./libraries/microsoft-agents-activity/ --config-settings editable pip install -e ./libraries/microsoft-agents-authentication-msal/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-authentication-entra-auth-sidecar/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-copilotstudio-client/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-hosting-a2a/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-hosting-aiohttp/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-hosting-core/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-hosting-teams/ --config-settings editable_mode=compat diff --git a/tests/hosting_a2a/integration/test_blob_protocol.py b/tests/hosting_a2a/integration/test_blob_protocol.py index e78e9fc4c..8154beab7 100644 --- a/tests/hosting_a2a/integration/test_blob_protocol.py +++ b/tests/hosting_a2a/integration/test_blob_protocol.py @@ -204,4 +204,4 @@ async def test_jsonrpc_list_tasks_uses_blob_continuation_tokens(): assert len(second_ids) == 1 assert second_result.get("nextPageToken", "") == "" assert first_ids.isdisjoint(second_ids) - assert first_ids | second_ids == task_ids \ No newline at end of file + assert first_ids | second_ids == task_ids diff --git a/tests/hosting_a2a/server/test_routes.py b/tests/hosting_a2a/server/test_routes.py index 67d96c2cb..19610b099 100644 --- a/tests/hosting_a2a/server/test_routes.py +++ b/tests/hosting_a2a/server/test_routes.py @@ -129,6 +129,10 @@ async def get_agent_card(request, prefix): observed["request"].url == "https://example.com/a2a/.well-known/agent-card.json" ) assert observed["prefix"] == "/a2a" + assert response.headers["cache-control"] == "public, max-age=3600" + assert response.headers["etag"].startswith('"') + assert response.headers["etag"].endswith('"') + assert response.headers["last-modified"] @pytest.mark.asyncio From c3bae50bad18da2ed7902cff7c14cd343c43a13f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 5 Oct 2026 15:34:41 -0700 Subject: [PATCH 37/39] Reorganizing the a2a extension --- .../microsoft_agents/hosting/a2a/__init__.py | 8 ++-- .../hosting/a2a/a2a_adapter.py | 14 +++---- .../microsoft_agents/hosting/a2a/add_a2a.py | 42 ++++++++++++++++--- .../hosting/a2a/blob_task_store.py | 2 +- .../hosting/a2a/extension/__init__.py | 12 ++++++ .../{ => extension}/a2a_agent_extension.py | 0 .../hosting/a2a/{ => extension}/a2a_client.py | 0 .../a2a/{ => extension}/a2a_turn_context.py | 2 +- .../a2a/{ => extension}/route_handlers.py | 0 .../hosting/a2a/server/_utils.py | 24 +++++++++++ .../hosting/a2a/server/routes.py | 32 +++++++++----- .../pyproject.toml | 2 +- .../microsoft-agents-hosting-a2a/setup.py | 3 ++ .../hosting/core/_utils/_service_set.py | 2 +- 14 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/__init__.py rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{ => extension}/a2a_agent_extension.py (100%) rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{ => extension}/a2a_client.py (100%) rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{ => extension}/a2a_turn_context.py (98%) rename libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/{ => extension}/route_handlers.py (100%) create mode 100644 libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_utils.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py index e78c73430..bc05aff99 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/__init__.py @@ -8,10 +8,12 @@ create_rest_routes, SDKServerCallContextBuilder, ) +from .extension import ( + A2AAgentExtension, + A2AClient, + A2ATurnContext, +) from .a2a_adapter import A2AAdapter -from .a2a_agent_extension import A2AAgentExtension -from .a2a_client import A2AClient -from .a2a_turn_context import A2ATurnContext from .add_a2a import add_a2a __all__ = [ diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py index aa271227c..655b602f7 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_adapter.py @@ -65,7 +65,7 @@ from .activity import utils, A2AActivity from .activity.a2a_activity import _DEFAULT_USER_ID -from .a2a_turn_context import A2ATurnContext +from .extension.a2a_turn_context import A2ATurnContext from .server._constants import _CLAIMS_IDENTITY_KEY @@ -95,7 +95,6 @@ def __init__( :param agent_interfaces: The list of agent interfaces associated with the adapter. :param skills: The list of skills associated with the adapter. :param task_store: Optional task store for managing tasks. If not provided, an in-memory task store will be used. - :param agent_card_cache_max_age: The maximum age (in seconds) for caching the agent card. Default is 3600 seconds (1 hour). """ self.middleware_set = MiddlewareSet() @@ -116,13 +115,13 @@ def __init__( @property def skills(self) -> list[AgentSkill]: - """Get the list of skills associated with the adapter.""" - return self._skills + """Get a copy of the list of skills associated with the adapter.""" + return list(self._skills) @property def agent_interfaces(self) -> list[AgentInterface]: - """Get the list of agent interfaces associated with the adapter.""" - return self._agent_interfaces + """Get a copy of the list of agent interfaces associated with the adapter.""" + return list(self._agent_interfaces) @property def a2a_request_handler(self) -> RequestHandler: @@ -333,6 +332,7 @@ async def _on_streaming_response( context_id=message.context_id, artifact=artifact, append=False, + last_chunk=True, ) ) @@ -429,7 +429,7 @@ def _get_basic_agent_card(self) -> AgentCard: default_output_modes=["application/json"], skills=[], capabilities=AgentCapabilities( - extended_agent_card=True, + extended_agent_card=False, streaming=True, ), supported_interfaces=[], diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py index 49c479d29..ba02ad0ee 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/add_a2a.py @@ -26,18 +26,25 @@ def _create_jsonrpc_interface_routes( adapter: A2AAdapter, interface: AgentInterface, + _agent_card_cache_enabled: bool = False, + agent_card_cache_max_age: int = 3600, ) -> tuple[list[Route], list[BaseRoute]]: """Create JSON-RPC interface routes for the given agent interface. :param adapter: The A2AAdapter instance used to handle requests. :param interface: The agent interface to add routes for. + :param _agent_card_cache_enabled: Whether to enable caching for the agent card. Default is False. + :param agent_card_cache_max_age: The maximum age (in seconds) for caching the agent card. Default is 3600 seconds (1 hour). """ jsonrpc_routes = create_jsonrpc_routes( adapter.a2a_request_handler, rpc_url=interface.url ) agent_card_routes = create_agent_card_routes( - adapter.get_agent_card, f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}" + adapter.get_agent_card, + f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}", + _cache_enabled=_agent_card_cache_enabled, + cache_max_age=agent_card_cache_max_age, ) return jsonrpc_routes, agent_card_routes @@ -45,17 +52,24 @@ def _create_jsonrpc_interface_routes( def _create_http_interface_routes( adapter: A2AAdapter, interface: AgentInterface, + _agent_card_cache_enabled: bool = False, + agent_card_cache_max_age: int = 3600, ) -> tuple[list[BaseRoute], list[BaseRoute]]: """Create HTTP interface routes for the given agent interface. :param adapter: The A2AAdapter instance used to handle requests. :param interface: The agent interface to add routes for. + :param _agent_card_cache_enabled: Whether to enable caching for the agent card. Default is False. + :param agent_card_cache_max_age: The maximum age (in seconds) for caching the agent card. Default is 3600 seconds (1 hour). """ http_routes = create_rest_routes( adapter.a2a_request_handler, path_prefix=interface.url ) agent_card_routes = create_agent_card_routes( - adapter.get_agent_card, f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}" + adapter.get_agent_card, + f"{interface.url}{AGENT_CARD_WELL_KNOWN_PATH}", + _cache_enabled=_agent_card_cache_enabled, + cache_max_age=agent_card_cache_max_age, ) return http_routes, agent_card_routes @@ -66,6 +80,8 @@ def add_a2a( adapter: A2AAdapter | None = None, *, use_jwt_middleware: bool = True, + _agent_card_cache_enabled: bool = False, + agent_card_cache_max_age: int = 3600, ): """Add A2A support to the given FastAPI app using the specified agent and adapter. @@ -73,9 +89,19 @@ def add_a2a( :param agent: The agent to use for A2A communication. :param adapter: An optional A2AAdapter instance. If not provided, a new one will be created using the agent. :param use_jwt_middleware: Whether to use JWT middleware for the routes. + :param _agent_card_cache_enabled: Whether to enable caching for the agent card. Default is False. + :param agent_card_cache_max_age: The maximum age (in seconds) for caching the agent card. Default is 3600 seconds (1 hour). """ - adapter = adapter or A2AAdapter(agent) + adapter = adapter or A2AAdapter( + agent, + agent_interfaces=[ + AgentInterface( + url="/a2a", + protocol_binding=TransportProtocol.JSONRPC, + ) + ], + ) agent_card_routes: list[BaseRoute] = [] jsonrpc_routes: list[BaseRoute] = [] @@ -91,13 +117,19 @@ def add_a2a( for interface in interfaces: if interface.protocol_binding == TransportProtocol.JSONRPC: _jsonrpc_routes, _agent_card_routes = _create_jsonrpc_interface_routes( - adapter, interface + adapter, + interface, + _agent_card_cache_enabled=_agent_card_cache_enabled, + agent_card_cache_max_age=agent_card_cache_max_age, ) jsonrpc_routes.extend(_jsonrpc_routes) agent_card_routes.extend(_agent_card_routes) elif interface.protocol_binding == TransportProtocol.HTTP_JSON: _http_routes, _agent_card_routes = _create_http_interface_routes( - adapter, interface + adapter, + interface, + _agent_card_cache_enabled=_agent_card_cache_enabled, + agent_card_cache_max_age=agent_card_cache_max_age, ) rest_routes.extend(_http_routes) agent_card_routes.extend(_agent_card_routes) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py index b8259aa76..1481c6ce0 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/blob_task_store.py @@ -25,7 +25,7 @@ is_status_code_error, ) -_TASK_PREFIX = "TODO" +_TASK_PREFIX = "a2atask/" class BlobTaskStore(TaskStore): diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/__init__.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/__init__.py new file mode 100644 index 000000000..136d19cb0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/__init__.py @@ -0,0 +1,12 @@ +from .a2a_agent_extension import A2AAgentExtension +from .a2a_client import A2AClient +from .a2a_turn_context import A2ATurnContext +from .route_handlers import wrap_a2a_route_handler, A2ARouteHandler + +__all__ = [ + "A2AAgentExtension", + "A2AClient", + "A2ATurnContext", + "wrap_a2a_route_handler", + "A2ARouteHandler", +] diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/a2a_agent_extension.py similarity index 100% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_agent_extension.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/a2a_agent_extension.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/a2a_client.py similarity index 100% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_client.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/a2a_client.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/a2a_turn_context.py similarity index 98% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/a2a_turn_context.py index cb0991055..d4026678a 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/a2a_turn_context.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/a2a_turn_context.py @@ -17,7 +17,7 @@ ClaimsIdentity, ) -from .activity import A2AActivity +from ..activity import A2AActivity from .a2a_client import A2AClient diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/route_handlers.py rename to libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/extension/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_utils.py new file mode 100644 index 000000000..b7cf55839 --- /dev/null +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_utils.py @@ -0,0 +1,24 @@ +from urllib.parse import urlsplit + +from a2a.types import AgentInterface + + +def _get_interface_route_path(interface: AgentInterface) -> str: + parsed = urlsplit(interface.url) + + if parsed.query or parsed.fragment: + raise ValueError( + "Agent interface URLs cannot contain a query string or fragment." + ) + + if parsed.scheme or parsed.netloc: + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError(f"Invalid HTTP agent interface URL: {interface.url}") + path = parsed.path + else: + path = parsed.path + + if not path.startswith("/"): + raise ValueError("Relative agent interface URLs must start with '/'.") + + return path.rstrip("/") or "/" diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index f0f33671c..984980cf9 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -30,6 +30,7 @@ ) from .sdk_server_call_context_builder import SDKServerCallContextBuilder +from ._utils import _get_interface_route_path _AGENT_CARD_CACHE_CONTROL_TEMPLATE = "public, max-age={}" @@ -48,7 +49,7 @@ def create_jsonrpc_routes( """ return _create_jsonrpc_routes( request_handler, - rpc_url, + __get_interface_route_path(rpc_url), context_builder=SDKServerCallContextBuilder(), enable_v0_3_compat=enable_v0_3_compat, ) @@ -70,7 +71,7 @@ def create_rest_routes( request_handler, context_builder=SDKServerCallContextBuilder(), enable_v0_3_compat=enable_v0_3_compat, - path_prefix=path_prefix, + path_prefix=__get_interface_route_path(path_prefix), ) return routes @@ -78,16 +79,22 @@ def create_rest_routes( def create_agent_card_routes( get_agent_card: Callable[[HttpRequestProtocol, str], Awaitable[AgentCard]], card_url: str = AGENT_CARD_WELL_KNOWN_PATH, + *, + _cache_enabled: bool = False, cache_max_age: int = 3600, # seconds, so 1 hour ) -> list[BaseRoute]: """Create routes for serving the agent card. :param get_agent_card: A callable that takes an HttpRequestProtocol and returns an AgentCard. :param card_url: The URL path for the agent card endpoint. - :param enable_v0_3_compat: Whether to enable compatibility with version 0.3. + :param _cache_enabled: Whether to enable public caching for the agent card. Default is False. + :param cache_max_age: The maximum age, in seconds, for caching the agent card. :return: A list of Route objects representing the agent card routes. """ + if cache_max_age < 0: + raise ValueError("cache_max_age must be non-negative") + prefix = card_url try: i = card_url.index(AGENT_CARD_WELL_KNOWN_PATH) @@ -102,13 +109,18 @@ async def _get_agent_card(request: Request) -> Response: """Retruns the public AgentCard describing this agent's capabilities, supported transports, and skills.""" card = await get_agent_card(FastApiRequestAdapter(request), prefix) response = JSONResponse(agent_card_to_dict(card)) - etag = f'"{hashlib.sha256(response.body).hexdigest()}"' - headers = { - "Cache-Control": _AGENT_CARD_CACHE_CONTROL_TEMPLATE.format(cache_max_age), - "ETag": etag, - "Last-Modified": last_modified, - } - response.headers.update(headers) + if _cache_enabled: + etag = f'"{hashlib.sha256(response.body).hexdigest()}"' + headers = { + "Cache-Control": _AGENT_CARD_CACHE_CONTROL_TEMPLATE.format( + cache_max_age + ), + "ETag": etag, + "Last-Modified": last_modified, + } + response.headers.update(headers) + else: + response.headers["Cache-Control"] = "no-store" return response return [Route(path=card_url, endpoint=_get_agent_card, methods=["GET"])] diff --git a/libraries/microsoft-agents-hosting-a2a/pyproject.toml b/libraries/microsoft-agents-hosting-a2a/pyproject.toml index d1132cb97..a271caa56 100644 --- a/libraries/microsoft-agents-hosting-a2a/pyproject.toml +++ b/libraries/microsoft-agents-hosting-a2a/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "microsoft-agents-hosting-a2a" -dynamic = ["version", "dependencies"] +dynamic = ["version", "dependencies", "optional-dependencies"] description = "Library adding A2A handling to an SDK Agent" readme = {file = "readme.md", content-type = "text/markdown"} authors = [{name = "Microsoft Corporation"}] diff --git a/libraries/microsoft-agents-hosting-a2a/setup.py b/libraries/microsoft-agents-hosting-a2a/setup.py index f2166db99..19d63719c 100644 --- a/libraries/microsoft-agents-hosting-a2a/setup.py +++ b/libraries/microsoft-agents-hosting-a2a/setup.py @@ -16,4 +16,7 @@ f"microsoft-agents-hosting-fastapi=={package_version}", "a2a-sdk>=1.0.0", ], + extras_require={ + "blob": ["azure-storage-blob"], + }, ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py index adc1a15b4..5720d6427 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_utils/_service_set.py @@ -24,7 +24,7 @@ def __init__(self, service_set: _ServiceSet | None = None) -> None: self._state.update(service_set._state) @overload - def get(self, key: type[T], raise_if_missing: Literal[True] = True) -> T: ... + def get(self, key: type[T], raise_if_missing: Literal[True]) -> T: ... @overload def get( From 9ccb9f96835e189f2673eb7c93a808d2427d99bb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 5 Oct 2026 15:35:46 -0700 Subject: [PATCH 38/39] Updating tests --- .../hosting_a2a/integration/test_protocol.py | 17 +++---- tests/hosting_a2a/server/test_routes.py | 4 ++ tests/hosting_a2a/server/test_utils.py | 25 +++++++++ tests/hosting_a2a/test_a2a_adapter.py | 1 + tests/hosting_a2a/test_a2a_agent_extension.py | 2 +- tests/hosting_a2a/test_a2a_client.py | 2 +- tests/hosting_a2a/test_a2a_turn_context.py | 2 +- tests/hosting_a2a/test_add_a2a.py | 51 +++++++++++++++++++ tests/hosting_a2a/test_blob_task_store.py | 12 +++-- tests/hosting_core/test_service_set.py | 7 +++ 10 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 tests/hosting_a2a/server/test_utils.py diff --git a/tests/hosting_a2a/integration/test_protocol.py b/tests/hosting_a2a/integration/test_protocol.py index a3c2eb370..0c35b7d87 100644 --- a/tests/hosting_a2a/integration/test_protocol.py +++ b/tests/hosting_a2a/integration/test_protocol.py @@ -788,13 +788,10 @@ async def test_rest_push_notification_config_is_explicitly_unsupported( @pytest.mark.integration @pytest.mark.asyncio -async def test_jsonrpc_extended_agent_card_is_explicitly_unconfigured( +async def test_jsonrpc_extended_agent_card_is_explicitly_unsupported( a2a_client: httpx.AsyncClient, ) -> None: - """The basic agent card advertises `capabilities.extended_agent_card`, - but the adapter never configures an actual extended card or modifier on - the request handler, so retrieval must fail with the protocol-defined - EXTENDED_AGENT_CARD_NOT_CONFIGURED error.""" + """The basic agent card does not advertise extended Agent Card support.""" response = await _post_rpc( a2a_client, @@ -803,14 +800,14 @@ async def test_jsonrpc_extended_agent_card_is_explicitly_unconfigured( request_id="extended-card", ) - assert response["error"]["code"] == -32007 + assert response["error"]["code"] == -32004 error_details = response["error"]["data"][0] - assert error_details["reason"] == "EXTENDED_AGENT_CARD_NOT_CONFIGURED" + assert error_details["reason"] == "UNSUPPORTED_OPERATION" @pytest.mark.integration @pytest.mark.asyncio -async def test_rest_extended_agent_card_is_explicitly_unconfigured( +async def test_rest_extended_agent_card_is_explicitly_unsupported( a2a_client: httpx.AsyncClient, ) -> None: response = await a2a_client.get( @@ -821,6 +818,4 @@ async def test_rest_extended_agent_card_is_explicitly_unconfigured( assert response.status_code == 400 payload = response.json() assert payload["error"]["status"] == "FAILED_PRECONDITION" - assert payload["error"]["details"][0]["reason"] == ( - "EXTENDED_AGENT_CARD_NOT_CONFIGURED" - ) + assert payload["error"]["details"][0]["reason"] == "UNSUPPORTED_OPERATION" diff --git a/tests/hosting_a2a/server/test_routes.py b/tests/hosting_a2a/server/test_routes.py index 19610b099..eeb9f5a45 100644 --- a/tests/hosting_a2a/server/test_routes.py +++ b/tests/hosting_a2a/server/test_routes.py @@ -102,6 +102,7 @@ async def get_agent_card(request, prefix): route = routes.create_agent_card_routes( get_agent_card, "/a2a/.well-known/agent-card.json", + _cache_enabled=True, )[0] request = Request( { @@ -171,6 +172,9 @@ async def get_agent_card(request, prefix): assert response.status_code == 200 assert observed["prefix"] == "/custom-agent-card" + assert response.headers["cache-control"] == "no-store" + assert "etag" not in response.headers + assert "last-modified" not in response.headers @pytest.mark.asyncio diff --git a/tests/hosting_a2a/server/test_utils.py b/tests/hosting_a2a/server/test_utils.py new file mode 100644 index 000000000..1ffd992ff --- /dev/null +++ b/tests/hosting_a2a/server/test_utils.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from a2a.types import AgentInterface +from a2a.utils.constants import TransportProtocol + +from microsoft_agents.hosting.a2a.server._utils import _get_interface_route_path + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("/a2a", "/a2a"), + ("https://example.com/a2a", "/a2a"), + ("https://example.com", "/"), + ], +) +def test_get_interface_route_path(url, expected): + interface = AgentInterface( + url=url, + protocol_binding=TransportProtocol.JSONRPC, + ) + + assert _get_interface_route_path(interface) == expected diff --git a/tests/hosting_a2a/test_a2a_adapter.py b/tests/hosting_a2a/test_a2a_adapter.py index 81eb8f083..78496d160 100644 --- a/tests/hosting_a2a/test_a2a_adapter.py +++ b/tests/hosting_a2a/test_a2a_adapter.py @@ -295,6 +295,7 @@ async def test_send_activities_emits_protocol_events_for_supported_activities(): assert artifact_event.context_id == "context-1" assert artifact_event.artifact.artifact_id == "stream-1" assert artifact_event.artifact.parts[0].text == "chunk" + assert artifact_event.last_chunk is True assert isinstance(message_event, TaskStatusUpdateEvent) assert message_event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED diff --git a/tests/hosting_a2a/test_a2a_agent_extension.py b/tests/hosting_a2a/test_a2a_agent_extension.py index 50eda6036..60a4b8f86 100644 --- a/tests/hosting_a2a/test_a2a_agent_extension.py +++ b/tests/hosting_a2a/test_a2a_agent_extension.py @@ -12,7 +12,7 @@ from microsoft_agents.activity import Activity from microsoft_agents.hosting.core import ChannelAdapter, ClaimsIdentity, TurnContext from microsoft_agents.hosting.a2a.a2a_agent_extension import A2AAgentExtension -from microsoft_agents.hosting.a2a.a2a_turn_context import A2ATurnContext +from microsoft_agents.hosting.a2a.extension.a2a_turn_context import A2ATurnContext class _Adapter(ChannelAdapter): diff --git a/tests/hosting_a2a/test_a2a_client.py b/tests/hosting_a2a/test_a2a_client.py index f573c0616..1c0ddb103 100644 --- a/tests/hosting_a2a/test_a2a_client.py +++ b/tests/hosting_a2a/test_a2a_client.py @@ -9,7 +9,7 @@ from a2a.server.events import EventQueue, EventQueueLegacy from a2a.server.tasks import InMemoryTaskStore, TaskStore -from microsoft_agents.hosting.a2a.a2a_client import A2AClient +from microsoft_agents.hosting.a2a.extension.a2a_client import A2AClient class _Services: diff --git a/tests/hosting_a2a/test_a2a_turn_context.py b/tests/hosting_a2a/test_a2a_turn_context.py index 018b77e46..3c72ef3c0 100644 --- a/tests/hosting_a2a/test_a2a_turn_context.py +++ b/tests/hosting_a2a/test_a2a_turn_context.py @@ -16,7 +16,7 @@ TurnContext, ) from microsoft_agents.hosting.a2a.activity import A2AActivity -from microsoft_agents.hosting.a2a.a2a_turn_context import A2ATurnContext +from microsoft_agents.hosting.a2a.extension.a2a_turn_context import A2ATurnContext class _Adapter(ChannelAdapter): diff --git a/tests/hosting_a2a/test_add_a2a.py b/tests/hosting_a2a/test_add_a2a.py index 3b3198b14..f97fdbccc 100644 --- a/tests/hosting_a2a/test_add_a2a.py +++ b/tests/hosting_a2a/test_add_a2a.py @@ -32,6 +32,20 @@ def _adapter(*interfaces): ) +def test_add_a2a_uses_default_jsonrpc_interface(): + app = FastAPI() + + add_a2a( + app, + MagicMock(), + use_jwt_middleware=False, + ) + + paths = {route.path for route in app.routes} + assert "/a2a" in paths + assert "/a2a/.well-known/agent-card.json" in paths + + @pytest.mark.asyncio async def test_add_a2a_exposes_configured_jsonrpc_and_http_interfaces(): app = FastAPI() @@ -67,6 +81,8 @@ async def test_add_a2a_exposes_configured_jsonrpc_and_http_interfaces(): assert rest_card.status_code == 200 assert rpc_card.json()["name"] == "Test agent" assert rest_card.json()["name"] == "Test agent" + assert rpc_card.headers["cache-control"] == "no-store" + assert rest_card.headers["cache-control"] == "no-store" assert { "/rpc", "/rpc/.well-known/agent-card.json", @@ -76,6 +92,41 @@ async def test_add_a2a_exposes_configured_jsonrpc_and_http_interfaces(): }.issubset(app.openapi()["paths"]) +@pytest.mark.asyncio +async def test_add_a2a_enables_agent_card_caching_for_all_interfaces(): + app = FastAPI() + adapter = _adapter( + AgentInterface( + url="/rpc", + protocol_binding=TransportProtocol.JSONRPC, + ), + AgentInterface( + url="/rest", + protocol_binding=TransportProtocol.HTTP_JSON, + ), + ) + add_a2a( + app, + MagicMock(), + adapter, + use_jwt_middleware=False, + _agent_card_cache_enabled=True, + agent_card_cache_max_age=300, + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + rpc_card = await client.get("/rpc/.well-known/agent-card.json") + rest_card = await client.get("/rest/.well-known/agent-card.json") + + for response in (rpc_card, rest_card): + assert response.headers["cache-control"] == "public, max-age=300" + assert response.headers["etag"] + assert response.headers["last-modified"] + + @pytest.mark.asyncio async def test_add_a2a_applies_jwt_middleware_to_registered_routes(): app = FastAPI() diff --git a/tests/hosting_a2a/test_blob_task_store.py b/tests/hosting_a2a/test_blob_task_store.py index 05e22ac7d..782c8b2f0 100644 --- a/tests/hosting_a2a/test_blob_task_store.py +++ b/tests/hosting_a2a/test_blob_task_store.py @@ -91,6 +91,12 @@ def test_constructor_rejects_invalid_parameter_combinations(kwargs): BlobTaskStore(**kwargs) +def test_blob_name_uses_a2a_task_namespace_and_url_encoding(): + assert ( + BlobTaskStore._get_blob_name("task/with spaces") == "a2atask/task%2Fwith+spaces" + ) + + @pytest.mark.blob class TestBlobTaskStore: @pytest.mark.asyncio @@ -107,7 +113,7 @@ async def test_save_get_and_overwrite_task(self): assert saved is not task blob_names = [blob.name async for blob in container_client.list_blobs()] - assert blob_names == ["TODOtask%2Fwith+spaces"] + assert blob_names == ["a2atask/task%2Fwith+spaces"] task.status.state = TaskState.TASK_STATE_COMPLETED await store.save(task, context) @@ -126,7 +132,7 @@ async def test_external_blob_change_is_visible(self): await _upload_blob( container_client, - "TODOexternal-task", + "a2atask/external-task", external_task.SerializeToString(), ) @@ -145,7 +151,7 @@ async def test_get_rejects_corrupted_task_blob(self): assert await store.get("corrupted-task", context) is None await _upload_blob( container_client, - "TODOcorrupted-task", + "a2atask/corrupted-task", b"not a serialized Task", ) diff --git a/tests/hosting_core/test_service_set.py b/tests/hosting_core/test_service_set.py index 1e720091c..ce6e2b01b 100644 --- a/tests/hosting_core/test_service_set.py +++ b/tests/hosting_core/test_service_set.py @@ -31,6 +31,13 @@ def test_get_returns_none_for_missing_service(): assert services.get(Service) is None +def test_get_raises_for_missing_service_when_requested(): + services = _ServiceSet() + + with pytest.raises(KeyError, match="Service.*missing"): + services.get(Service, raise_if_missing=True) + + def test_has_returns_false_for_missing_service(): services = _ServiceSet() From a24215942ee970cdc789cdaa3dd966990f1e6d94 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 5 Oct 2026 15:36:47 -0700 Subject: [PATCH 39/39] Updating tests --- .../microsoft_agents/hosting/a2a/server/_utils.py | 6 +++--- .../microsoft_agents/hosting/a2a/server/routes.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_utils.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_utils.py index b7cf55839..807fe2699 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_utils.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/_utils.py @@ -3,8 +3,8 @@ from a2a.types import AgentInterface -def _get_interface_route_path(interface: AgentInterface) -> str: - parsed = urlsplit(interface.url) +def _get_interface_route_path(url: str) -> str: + parsed = urlsplit(url) if parsed.query or parsed.fragment: raise ValueError( @@ -13,7 +13,7 @@ def _get_interface_route_path(interface: AgentInterface) -> str: if parsed.scheme or parsed.netloc: if parsed.scheme not in ("http", "https") or not parsed.netloc: - raise ValueError(f"Invalid HTTP agent interface URL: {interface.url}") + raise ValueError(f"Invalid HTTP agent interface URL: {url}") path = parsed.path else: path = parsed.path diff --git a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py index 984980cf9..8136184b5 100644 --- a/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py +++ b/libraries/microsoft-agents-hosting-a2a/microsoft_agents/hosting/a2a/server/routes.py @@ -49,7 +49,7 @@ def create_jsonrpc_routes( """ return _create_jsonrpc_routes( request_handler, - __get_interface_route_path(rpc_url), + _get_interface_route_path(rpc_url), context_builder=SDKServerCallContextBuilder(), enable_v0_3_compat=enable_v0_3_compat, ) @@ -71,7 +71,7 @@ def create_rest_routes( request_handler, context_builder=SDKServerCallContextBuilder(), enable_v0_3_compat=enable_v0_3_compat, - path_prefix=__get_interface_route_path(path_prefix), + path_prefix=_get_interface_route_path(path_prefix), ) return routes