From c7423e817e41d3a3a4d0b227a1bb405f60975ba3 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Thu, 16 Oct 2025 09:21:41 -0700 Subject: [PATCH 01/19] Fastapi integration draft --- .../microsoft-agents-hosting-fastapi/LICENSE | 21 + .../microsoft_agents/__init__.py | 1 + .../microsoft_agents/hosting/__init__.py | 1 + .../hosting/fastapi/__init__.py | 27 ++ .../hosting/fastapi/_start_agent_process.py | 26 ++ .../hosting/fastapi/agent_http_adapter.py | 15 + .../hosting/fastapi/app/__init__.py | 14 + .../hosting/fastapi/app/streaming/__init__.py | 12 + .../hosting/fastapi/app/streaming/citation.py | 22 + .../fastapi/app/streaming/citation_util.py | 85 ++++ .../app/streaming/streaming_response.py | 392 ++++++++++++++++++ .../fastapi/channel_service_route_table.py | 205 +++++++++ .../hosting/fastapi/cloud_adapter.py | 113 +++++ .../fastapi/jwt_authorization_middleware.py | 160 +++++++ .../pyproject.toml | 20 + .../readme.md | 33 ++ .../microsoft-agents-hosting-fastapi/setup.py | 12 + 17 files changed, 1159 insertions(+) create mode 100644 libraries/microsoft-agents-hosting-fastapi/LICENSE create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/__init__.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/__init__.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_start_agent_process.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/agent_http_adapter.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/__init__.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/__init__.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation_util.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/channel_service_route_table.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py create mode 100644 libraries/microsoft-agents-hosting-fastapi/pyproject.toml create mode 100644 libraries/microsoft-agents-hosting-fastapi/readme.md create mode 100644 libraries/microsoft-agents-hosting-fastapi/setup.py diff --git a/libraries/microsoft-agents-hosting-fastapi/LICENSE b/libraries/microsoft-agents-hosting-fastapi/LICENSE new file mode 100644 index 00000000..ce29e72a --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/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. \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/__init__.py new file mode 100644 index 00000000..eee2b9b5 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/__init__.py @@ -0,0 +1 @@ +# Microsoft Agents packages diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/__init__.py new file mode 100644 index 00000000..7275ee7b --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/__init__.py @@ -0,0 +1 @@ +# Microsoft Agents Hosting packages diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py new file mode 100644 index 00000000..1ffcd7c5 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py @@ -0,0 +1,27 @@ +from ._start_agent_process import start_agent_process +from .agent_http_adapter import AgentHttpAdapter +from .channel_service_route_table import channel_service_route_table +from .cloud_adapter import CloudAdapter +from .jwt_authorization_middleware import ( + jwt_authorization_dependency, + jwt_authorization_decorator, + JwtAuthorizationMiddleware, +) +from .app.streaming import ( + Citation, + CitationUtil, + StreamingResponse, +) + +__all__ = [ + "start_agent_process", + "AgentHttpAdapter", + "CloudAdapter", + "jwt_authorization_dependency", + "jwt_authorization_decorator", + "JwtAuthorizationMiddleware", + "channel_service_route_table", + "Citation", + "CitationUtil", + "StreamingResponse", +] diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_start_agent_process.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_start_agent_process.py new file mode 100644 index 00000000..13396ca8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_start_agent_process.py @@ -0,0 +1,26 @@ +from typing import Optional +from fastapi import Request, Response +from microsoft_agents.hosting.core.app import AgentApplication +from .cloud_adapter import CloudAdapter + + +async def start_agent_process( + request: Request, + agent_application: AgentApplication, + adapter: CloudAdapter, +) -> Optional[Response]: + """Starts the agent host with the provided adapter and agent application. + Args: + adapter (CloudAdapter): The adapter to use for the agent host. + agent_application (AgentApplication): The agent application to run. + """ + if not adapter: + raise TypeError("start_agent_process: adapter can't be None") + if not agent_application: + raise TypeError("start_agent_process: agent_application can't be None") + + # Start the agent application with the provided adapter + return await adapter.process( + request, + agent_application, + ) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/agent_http_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/agent_http_adapter.py new file mode 100644 index 00000000..2584b272 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/agent_http_adapter.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import abstractmethod +from typing import Optional, Protocol + +from fastapi import Request, Response + +from microsoft_agents.hosting.core import Agent + + +class AgentHttpAdapter(Protocol): + @abstractmethod + async def process(self, request: Request, agent: Agent) -> Optional[Response]: + raise NotImplementedError() diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/__init__.py new file mode 100644 index 00000000..8216be63 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .streaming import ( + Citation, + CitationUtil, + StreamingResponse, +) + +__all__ = [ + "Citation", + "CitationUtil", + "StreamingResponse", +] diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/__init__.py new file mode 100644 index 00000000..4cd61f38 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .citation import Citation +from .citation_util import CitationUtil +from .streaming_response import StreamingResponse + +__all__ = [ + "Citation", + "CitationUtil", + "StreamingResponse", +] diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation.py new file mode 100644 index 00000000..f643639a --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Optional +from dataclasses import dataclass + + +@dataclass +class Citation: + """Citations returned by the model.""" + + content: str + """The content of the citation.""" + + title: Optional[str] = None + """The title of the citation.""" + + url: Optional[str] = None + """The URL of the citation.""" + + filepath: Optional[str] = None + """The filepath of the document.""" diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation_util.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation_util.py new file mode 100644 index 00000000..1ec923dc --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/citation_util.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +from typing import List, Optional + +from microsoft_agents.activity import ClientCitation + + +class CitationUtil: + """Utility functions for manipulating text and citations.""" + + @staticmethod + def snippet(text: str, max_length: int) -> str: + """ + Clips the text to a maximum length in case it exceeds the limit. + + Args: + text: The text to clip. + max_length: The maximum length of the text to return, cutting off the last whole word. + + Returns: + The modified text + """ + if len(text) <= max_length: + return text + + snippet = text[:max_length] + snippet = snippet[: min(len(snippet), snippet.rfind(" "))] + snippet += "..." + return snippet + + @staticmethod + def format_citations_response(text: str) -> str: + """ + Convert citation tags `[doc(s)n]` to `[n]` where n is a number. + + Args: + text: The text to format. + + Returns: + The formatted text. + """ + return re.sub(r"\[docs?(\d+)\]", r"[\1]", text, flags=re.IGNORECASE) + + @staticmethod + def get_used_citations( + text: str, citations: List[ClientCitation] + ) -> Optional[List[ClientCitation]]: + """ + Get the citations used in the text. This will remove any citations that are + included in the citations array from the response but not referenced in the text. + + Args: + text: The text to search for citation references, i.e. [1], [2], etc. + citations: The list of citations to search for. + + Returns: + The list of citations used in the text. + """ + regex = re.compile(r"\[(\d+)\]", re.IGNORECASE) + matches = regex.findall(text) + + if not matches: + return None + + # Remove duplicates + filtered_matches = set(matches) + + # Add citations + used_citations = [] + for match in filtered_matches: + citation_ref = f"[{match}]" + found = next( + ( + citation + for citation in citations + if f"[{citation.position}]" == citation_ref + ), + None, + ) + if found: + used_citations.append(found) + + return used_citations if used_citations else None diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py new file mode 100644 index 00000000..1155db0f --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py @@ -0,0 +1,392 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import logging +from typing import List, Optional, Callable, Literal, TYPE_CHECKING +from dataclasses import dataclass + +from microsoft_agents.activity import ( + Activity, + Entity, + Attachment, + Channels, + ClientCitation, + DeliveryModes, + SensitivityUsageInfo, +) + +if TYPE_CHECKING: + from microsoft_agents.hosting.core.turn_context import TurnContext + +from .citation import Citation +from .citation_util import CitationUtil + +logger = logging.getLogger(__name__) + + +class StreamingResponse: + """ + A helper class for streaming responses to the client. + + This class is used to send a series of updates to the client in a single response. + The expected sequence of calls is: + + `queue_informative_update()`, `queue_text_chunk()`, `queue_text_chunk()`, ..., `end_stream()`. + + Once `end_stream()` is called, the stream is considered ended and no further updates can be sent. + """ + + def __init__(self, context: "TurnContext"): + """ + Creates a new StreamingResponse instance. + + Args: + context: Context for the current turn of conversation with the user. + """ + self._context = context + self._sequence_number = 1 + self._stream_id: Optional[str] = None + self._message = "" + self._queue: List[Callable[[], Activity]] = [] + self._queue_sync: Optional[asyncio.Task] = None + self._chunk_queued = False + self._ended = False + self._cancelled = False + self._is_streaming_channel = False + self._interval = 0.1 + self._channel_id: Optional[str] = None + self._attachments: Optional[List[Attachment]] = None + self._citations: Optional[List[ClientCitation]] = None + self._sensitivity_label: Optional[SensitivityUsageInfo] = None + self._enable_feedback_loop = False + self._feedback_loop_type: Optional[Literal["default", "custom"]] = None + self._enable_generated_by_ai_label = False + + # Set defaults based on channel + self._set_defaults(context) + + def queue_informative_update(self, text: str) -> None: + """ + Queues an informative update to be sent to the client. + + Informative updates do not contain the message content that the user will + read but rather an indication that the agent is processing the request. + + Args: + text: The informative text to send to the client. + """ + if self._cancelled: + return + + if self._ended: + raise RuntimeError("The stream has already ended.") + + # Queue a typing activity + def create_activity(): + activity = Activity( + type="typing", + text=text, + entities=[ + Entity( + type="streaminfo", + stream_type="informative", + stream_sequence=self._sequence_number, + ) + ], + ) + self._sequence_number += 1 + return activity + + self._queue_activity(create_activity) + + def queue_text_chunk( + self, text: str, citations: Optional[List[Citation]] = None + ) -> None: + """ + Queues a chunk of partial message text to be sent to the client. + + The text will be sent as quickly as possible to the client. + Chunks may be combined before delivery to the client. + + Args: + text: Partial text of the message to send. + citations: Citations to be included in the message. + """ + if self._cancelled: + return + if self._ended: + raise RuntimeError("The stream has already ended.") + + # Update full message text + self._message += text + + # If there are citations, modify the content so that the sources are numbers instead of [doc1], [doc2], etc. + self._message = CitationUtil.format_citations_response(self._message) + + # Queue the next chunk + self._queue_next_chunk() + + async def end_stream(self) -> None: + """ + Ends the stream by sending the final message to the client. + """ + if self._ended: + raise RuntimeError("The stream has already ended.") + + # Queue final message + self._ended = True + self._queue_next_chunk() + + # Wait for the queue to drain + await self.wait_for_queue() + + def set_attachments(self, attachments: List[Attachment]) -> None: + """ + Sets the attachments to attach to the final chunk. + + Args: + attachments: List of attachments. + """ + self._attachments = attachments + + def set_sensitivity_label(self, sensitivity_label: SensitivityUsageInfo) -> None: + """ + Sets the sensitivity label to attach to the final chunk. + + Args: + sensitivity_label: The sensitivity label. + """ + self._sensitivity_label = sensitivity_label + + def set_citations(self, citations: List[Citation]) -> None: + """ + Sets the citations for the full message. + + Args: + citations: Citations to be included in the message. + """ + if citations: + if not self._citations: + self._citations = [] + + curr_pos = len(self._citations) + + for citation in citations: + client_citation = ClientCitation( + type="Claim", + position=curr_pos + 1, + appearance={ + "type": "DigitalDocument", + "name": citation.title or f"Document #{curr_pos + 1}", + "abstract": CitationUtil.snippet(citation.content, 477), + }, + ) + curr_pos += 1 + self._citations.append(client_citation) + + def set_feedback_loop(self, enable_feedback_loop: bool) -> None: + """ + Sets the Feedback Loop in Teams that allows a user to + give thumbs up or down to a response. + Default is False. + + Args: + enable_feedback_loop: If true, the feedback loop is enabled. + """ + self._enable_feedback_loop = enable_feedback_loop + + def set_feedback_loop_type( + self, feedback_loop_type: Literal["default", "custom"] + ) -> None: + """ + Sets the type of UI to use for the feedback loop. + + Args: + feedback_loop_type: The type of the feedback loop. + """ + self._feedback_loop_type = feedback_loop_type + + def set_generated_by_ai_label(self, enable_generated_by_ai_label: bool) -> None: + """ + Sets the Generated by AI label in Teams. + Default is False. + + Args: + enable_generated_by_ai_label: If true, the label is added. + """ + self._enable_generated_by_ai_label = enable_generated_by_ai_label + + def get_message(self) -> str: + """ + Returns the most recently streamed message. + """ + return self._message + + async def wait_for_queue(self) -> None: + """ + Waits for the outgoing activity queue to be empty. + """ + if self._queue_sync: + await self._queue_sync + + def _set_defaults(self, context: "TurnContext"): + if context.activity.channel_id == Channels.ms_teams: + self._is_streaming_channel = True + self._interval = 1.0 + elif context.activity.channel_id == Channels.direct_line: + self._is_streaming_channel = True + self._interval = 0.5 + elif context.activity.delivery_mode == DeliveryModes.stream: + self._is_streaming_channel = True + self._interval = 0.1 + + self._channel_id = context.activity.channel_id + + def _queue_next_chunk(self) -> None: + """ + Queues the next chunk of text to be sent to the client. + """ + # Are we already waiting to send a chunk? + if self._chunk_queued: + return + + # Queue a chunk of text to be sent + self._chunk_queued = True + + def create_activity(): + self._chunk_queued = False + if self._ended: + # Send final message + activity = Activity( + type="message", + text=self._message or "end stream response", + attachments=self._attachments or [], + entities=[ + Entity( + type="streaminfo", + stream_type="final", + stream_sequence=self._sequence_number, + ) + ], + ) + elif self._is_streaming_channel: + # Send typing activity + activity = Activity( + type="typing", + text=self._message, + entities=[ + Entity( + type="streaminfo", + stream_type="streaming", + stream_sequence=self._sequence_number, + ) + ], + ) + else: + return + self._sequence_number += 1 + return activity + + self._queue_activity(create_activity) + + def _queue_activity(self, factory: Callable[[], Activity]) -> None: + """ + Queues an activity to be sent to the client. + """ + self._queue.append(factory) + + # If there's no sync in progress, start one + if not self._queue_sync: + self._queue_sync = asyncio.create_task(self._drain_queue()) + + async def _drain_queue(self) -> None: + """ + Sends any queued activities to the client until the queue is empty. + """ + try: + logger.debug(f"Draining queue with {len(self._queue)} activities.") + while self._queue: + factory = self._queue.pop(0) + activity = factory() + if activity: + await self._send_activity(activity) + except Exception as err: + if ( + "403" in str(err) + and self._context.activity.channel_id == Channels.ms_teams + ): + logger.warning("Teams channel stopped the stream.") + self._cancelled = True + else: + logger.error( + f"Error occurred when sending activity while streaming: {err}" + ) + raise + finally: + self._queue_sync = None + + async def _send_activity(self, activity: Activity) -> None: + """ + Sends an activity to the client and saves the stream ID returned. + + Args: + activity: The activity to send. + """ + + streaminfo_entity = None + + if not activity.entities: + streaminfo_entity = Entity(type="streaminfo") + activity.entities = [streaminfo_entity] + else: + for entity in activity.entities: + if hasattr(entity, "type") and entity.type == "streaminfo": + streaminfo_entity = entity + break + + if not streaminfo_entity: + # If no streaminfo entity exists, create one + streaminfo_entity = Entity(type="streaminfo") + activity.entities.append(streaminfo_entity) + + # Set activity ID to the assigned stream ID + if self._stream_id: + activity.id = self._stream_id + streaminfo_entity.stream_id = self._stream_id + + if self._citations and len(self._citations) > 0 and not self._ended: + # Filter out the citations unused in content. + curr_citations = CitationUtil.get_used_citations( + self._message, self._citations + ) + if curr_citations: + activity.entities.append( + Entity( + type="https://schema.org/Message", + schema_type="Message", + context="https://schema.org", + id="", + citation=curr_citations, + ) + ) + + # Add in Powered by AI feature flags + if self._ended: + if self._enable_feedback_loop and self._feedback_loop_type: + # Add feedback loop to streaminfo entity + streaminfo_entity.feedback_loop = {"type": self._feedback_loop_type} + else: + # Add feedback loop enabled to streaminfo entity + streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop + # Add in Generated by AI + if self._enable_generated_by_ai_label: + activity.add_ai_metadata(self._citations, self._sensitivity_label) + + # Send activity + response = await self._context.send_activity(activity) + await asyncio.sleep(self._interval) + + # Save assigned stream ID + if not self._stream_id and response: + self._stream_id = response.id diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/channel_service_route_table.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/channel_service_route_table.py new file mode 100644 index 00000000..2dd009fc --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/channel_service_route_table.py @@ -0,0 +1,205 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import json +from typing import List, Union, Type + +from fastapi import APIRouter, Request, Response, HTTPException, Depends +from fastapi.responses import JSONResponse + +from microsoft_agents.activity import ( + AgentsModel, + Activity, + AttachmentData, + ConversationParameters, + Transcript, +) +from microsoft_agents.hosting.core import ChannelApiHandlerProtocol + + +async def deserialize_from_body( + request: Request, target_model: Type[AgentsModel] +) -> AgentsModel: + content_type = request.headers.get("Content-Type", "") + if "application/json" in content_type: + body = await request.json() + else: + raise HTTPException(status_code=415, detail="Unsupported Media Type") + + return target_model.model_validate(body) + + +def get_serialized_response( + model_or_list: Union[AgentsModel, List[AgentsModel]], +) -> JSONResponse: + if isinstance(model_or_list, AgentsModel): + json_obj = model_or_list.model_dump( + mode="json", exclude_unset=True, by_alias=True + ) + else: + json_obj = [ + model.model_dump(mode="json", exclude_unset=True, by_alias=True) + for model in model_or_list + ] + + return JSONResponse(content=json_obj) + + +def channel_service_route_table( + handler: ChannelApiHandlerProtocol, base_url: str = "" +) -> APIRouter: + router = APIRouter() + + @router.post(base_url + "/v3/conversations/{conversation_id}/activities") + async def send_to_conversation(conversation_id: str, request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.on_send_to_conversation( + getattr(request.state, "claims_identity", None), + conversation_id, + activity, + ) + + return get_serialized_response(result) + + @router.post( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def reply_to_activity( + conversation_id: str, activity_id: str, request: Request + ): + activity = await deserialize_from_body(request, Activity) + result = await handler.on_reply_to_activity( + getattr(request.state, "claims_identity", None), + conversation_id, + activity_id, + activity, + ) + + return get_serialized_response(result) + + @router.put( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def update_activity(conversation_id: str, activity_id: str, request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.on_update_activity( + getattr(request.state, "claims_identity", None), + conversation_id, + activity_id, + activity, + ) + + return get_serialized_response(result) + + @router.delete( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def delete_activity(conversation_id: str, activity_id: str, request: Request): + await handler.on_delete_activity( + getattr(request.state, "claims_identity", None), + conversation_id, + activity_id, + ) + + return Response(status_code=200) + + @router.get( + base_url + + "/v3/conversations/{conversation_id}/activities/{activity_id}/members" + ) + async def get_activity_members( + conversation_id: str, activity_id: str, request: Request + ): + result = await handler.on_get_activity_members( + getattr(request.state, "claims_identity", None), + conversation_id, + activity_id, + ) + + return get_serialized_response(result) + + @router.post(base_url + "/") + async def create_conversation(request: Request): + conversation_parameters = await deserialize_from_body( + request, ConversationParameters + ) + result = await handler.on_create_conversation( + getattr(request.state, "claims_identity", None), conversation_parameters + ) + + return get_serialized_response(result) + + @router.get(base_url + "/") + async def get_conversation(request: Request): + # TODO: continuation token? conversation_id? + result = await handler.on_get_conversations( + getattr(request.state, "claims_identity", None), None + ) + + return get_serialized_response(result) + + @router.get(base_url + "/v3/conversations/{conversation_id}/members") + async def get_conversation_members(conversation_id: str, request: Request): + result = await handler.on_get_conversation_members( + getattr(request.state, "claims_identity", None), + conversation_id, + ) + + return get_serialized_response(result) + + @router.get(base_url + "/v3/conversations/{conversation_id}/members/{member_id}") + async def get_conversation_member( + conversation_id: str, member_id: str, request: Request + ): + result = await handler.on_get_conversation_member( + getattr(request.state, "claims_identity", None), + member_id, + conversation_id, + ) + + return get_serialized_response(result) + + @router.get(base_url + "/v3/conversations/{conversation_id}/pagedmembers") + async def get_conversation_paged_members(conversation_id: str, request: Request): + # TODO: continuation token? page size? + result = await handler.on_get_conversation_paged_members( + getattr(request.state, "claims_identity", None), + conversation_id, + ) + + return get_serialized_response(result) + + @router.delete(base_url + "/v3/conversations/{conversation_id}/members/{member_id}") + async def delete_conversation_member( + conversation_id: str, member_id: str, request: Request + ): + result = await handler.on_delete_conversation_member( + getattr(request.state, "claims_identity", None), + conversation_id, + member_id, + ) + + return get_serialized_response(result) + + @router.post(base_url + "/v3/conversations/{conversation_id}/activities/history") + async def send_conversation_history(conversation_id: str, request: Request): + transcript = await deserialize_from_body(request, Transcript) + result = await handler.on_send_conversation_history( + getattr(request.state, "claims_identity", None), + conversation_id, + transcript, + ) + + return get_serialized_response(result) + + @router.post(base_url + "/v3/conversations/{conversation_id}/attachments") + async def upload_attachment(conversation_id: str, request: Request): + attachment_data = await deserialize_from_body(request, AttachmentData) + result = await handler.on_upload_attachment( + getattr(request.state, "claims_identity", None), + conversation_id, + attachment_data, + ) + + return get_serialized_response(result) + + return router 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 new file mode 100644 index 00000000..74f75e2f --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from traceback import format_exc +from typing import Optional +import json + +from fastapi import Request, Response, HTTPException +from fastapi.responses import JSONResponse +from microsoft_agents.hosting.core.authorization import ( + ClaimsIdentity, + Connections, +) +from microsoft_agents.activity import ( + Activity, + DeliveryModes, +) +from microsoft_agents.hosting.core import ( + Agent, + ChannelServiceAdapter, + ChannelServiceClientFactoryBase, + MessageFactory, + RestChannelServiceClientFactory, + TurnContext, +) + +from .agent_http_adapter import AgentHttpAdapter + + +class CloudAdapter(ChannelServiceAdapter, AgentHttpAdapter): + def __init__( + self, + *, + connection_manager: Connections = None, + channel_service_client_factory: ChannelServiceClientFactoryBase = None, + ): + """ + Initializes a new instance of the CloudAdapter class. + + :param channel_service_client_factory: The factory to use to create the channel service client. + """ + + async def on_turn_error(context: TurnContext, error: Exception): + error_message = f"Exception caught : {error}" + print(format_exc()) + + await context.send_activity(MessageFactory.text(error_message)) + + # Send a trace activity + await context.send_trace_activity( + "OnTurnError Trace", + error_message, + "https://www.botframework.com/schemas/error", + "TurnError", + ) + + self.on_turn_error = on_turn_error + + channel_service_client_factory = ( + channel_service_client_factory + or RestChannelServiceClientFactory(connection_manager) + ) + + super().__init__(channel_service_client_factory) + + async def process(self, request: Request, agent: Agent) -> Optional[Response]: + if not request: + raise TypeError("CloudAdapter.process: request can't be None") + if not agent: + raise TypeError("CloudAdapter.process: agent can't be None") + + if request.method == "POST": + # Deserialize the incoming Activity + content_type = request.headers.get("Content-Type", "") + if "application/json" in content_type: + body = await request.json() + else: + raise HTTPException(status_code=415, detail="Unsupported Media Type") + + activity: Activity = Activity.model_validate(body) + + # default to anonymous identity with no claims + claims_identity: ClaimsIdentity = getattr( + request.state, "claims_identity", ClaimsIdentity({}, False) + ) + + # A POST request must contain an Activity + if ( + not activity.type + or not activity.conversation + or not activity.conversation.id + ): + raise HTTPException(status_code=400, detail="Bad Request") + + try: + # Process the inbound activity with the agent + invoke_response = await self.process_activity( + claims_identity, activity, agent.on_turn + ) + + if ( + activity.type == "invoke" + or activity.delivery_mode == DeliveryModes.expect_replies + ): + # Invoke and ExpectReplies cannot be performed async, the response must be written before the calling thread is released. + return JSONResponse( + content=invoke_response.body, status_code=invoke_response.status + ) + + return Response(status_code=202) + except PermissionError: + raise HTTPException(status_code=401, detail="Unauthorized") + else: + raise HTTPException(status_code=405, detail="Method Not Allowed") diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py new file mode 100644 index 00000000..676cbbe9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -0,0 +1,160 @@ +import functools +from typing import Callable +from fastapi import Request, HTTPException, Depends +from fastapi.responses import JSONResponse + +from microsoft_agents.hosting.core.authorization import ( + AgentAuthConfiguration, + JwtTokenValidator, +) + + +async def jwt_authorization_dependency(request: Request): + """ + FastAPI dependency for JWT authorization. + + Usage: + @app.post("/api/messages") + async def messages(request: Request, _: None = Depends(jwt_authorization_dependency)): + # Your handler code here + """ + # Get auth configuration from app state + auth_config: AgentAuthConfiguration = getattr( + request.app.state, "agent_configuration", None + ) + if not auth_config: + raise HTTPException(status_code=500, detail="Agent configuration not found") + + token_validator = JwtTokenValidator(auth_config) + auth_header = request.headers.get("Authorization") + + if auth_header: + # Extract the token from the Authorization header + parts = auth_header.split(" ") + if len(parts) != 2 or parts[0].lower() != "bearer": + raise HTTPException( + status_code=401, detail="Invalid authorization header format" + ) + + token = parts[1] + try: + claims = token_validator.validate_token(token) + request.state.claims_identity = claims + except ValueError as e: + print(f"JWT validation error: {e}") + raise HTTPException(status_code=401, detail=str(e)) + else: + if not auth_config or not auth_config.CLIENT_ID: + # TODO: Refine anonymous strategy + request.state.claims_identity = token_validator.get_anonymous_claims() + else: + raise HTTPException( + status_code=401, detail="Authorization header not found" + ) + + +def jwt_authorization_decorator(func: Callable): + """ + Decorator for JWT authorization on individual route functions. + + Usage: + @jwt_authorization_decorator + async def messages(request: Request): + # Your handler code here + """ + + @functools.wraps(func) + async def wrapper(request: Request, *args, **kwargs): + # Get auth configuration from app state + auth_config: AgentAuthConfiguration = getattr( + request.app.state, "agent_configuration", None + ) + if not auth_config: + raise HTTPException(status_code=500, detail="Agent configuration not found") + + token_validator = JwtTokenValidator(auth_config) + auth_header = request.headers.get("Authorization") + + if auth_header: + # Extract the token from the Authorization header + parts = auth_header.split(" ") + if len(parts) != 2 or parts[0].lower() != "bearer": + raise HTTPException( + status_code=401, detail="Invalid authorization header format" + ) + + token = parts[1] + try: + claims = token_validator.validate_token(token) + request.state.claims_identity = claims + except ValueError as e: + print(f"JWT validation error: {e}") + raise HTTPException(status_code=401, detail=str(e)) + else: + if not auth_config.CLIENT_ID: + # TODO: Refine anonymous strategy + request.state.claims_identity = token_validator.get_anonymous_claims() + else: + raise HTTPException( + status_code=401, detail="Authorization header not found" + ) + + return await func(request, *args, **kwargs) + + return wrapper + + +class JwtAuthorizationMiddleware: + """ + Middleware class for JWT authorization in FastAPI. + + Usage: + from fastapi import FastAPI + from fastapi.middleware.base import BaseHTTPMiddleware + + app = FastAPI() + app.add_middleware(BaseHTTPMiddleware, dispatch=JwtAuthorizationMiddleware()) + """ + + def __init__(self): + pass + + async def __call__(self, request: Request, call_next): + # Get auth configuration from app state + auth_config: AgentAuthConfiguration = getattr( + request.app.state, "agent_configuration", None + ) + + if auth_config: + token_validator = JwtTokenValidator(auth_config) + auth_header = request.headers.get("Authorization") + + if auth_header: + # Extract the token from the Authorization header + parts = auth_header.split(" ") + if len(parts) == 2 and parts[0].lower() == "bearer": + token = parts[1] + try: + claims = token_validator.validate_token(token) + request.state.claims_identity = claims + except ValueError as e: + print(f"JWT validation error: {e}") + return JSONResponse({"error": str(e)}, status_code=401) + else: + return JSONResponse( + {"error": "Invalid authorization header format"}, + status_code=401, + ) + else: + if not auth_config.CLIENT_ID: + # TODO: Refine anonymous strategy + request.state.claims_identity = ( + token_validator.get_anonymous_claims() + ) + else: + return JSONResponse( + {"error": "Authorization header not found"}, status_code=401 + ) + + response = await call_next(request) + return response diff --git a/libraries/microsoft-agents-hosting-fastapi/pyproject.toml b/libraries/microsoft-agents-hosting-fastapi/pyproject.toml new file mode 100644 index 00000000..8ba1a6cd --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-hosting-fastapi" +dynamic = ["version", "dependencies"] +description = "Integration library for Microsoft Agents with FastAPI" +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", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-fastapi/readme.md b/libraries/microsoft-agents-hosting-fastapi/readme.md new file mode 100644 index 00000000..f899d0f7 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/readme.md @@ -0,0 +1,33 @@ +# Microsoft Agents Hosting FastAPI + +This library provides FastAPI integration for Microsoft Agents, enabling you to build conversational agents using the FastAPI web framework. + +## Features + +- FastAPI integration for Microsoft Agents +- JWT authorization middleware +- Channel service API endpoints +- Streaming response support +- Cloud adapter for processing agent activities + +## Installation + +```bash +pip install microsoft-agents-hosting-fastapi +``` + +## Usage + +```python +from fastapi import FastAPI, Request +from microsoft_agents.hosting.fastapi import start_agent_process, CloudAdapter +from microsoft_agents.hosting.core.app import AgentApplication + +app = FastAPI() +adapter = CloudAdapter() +agent_app = AgentApplication() + +@app.post("/api/messages") +async def messages(request: Request): + return await start_agent_process(request, agent_app, adapter) +``` \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-fastapi/setup.py b/libraries/microsoft-agents-hosting-fastapi/setup.py new file mode 100644 index 00000000..cfcb460b --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/setup.py @@ -0,0 +1,12 @@ +from os import environ +from setuptools import setup + +package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + f"microsoft-agents-hosting-core=={package_version}", + "fastapi>=0.104.0", + ], +) From 1a6aa8d284fe5f769a411bae9c7189f12024fbed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Thu, 16 Oct 2025 11:13:40 -0700 Subject: [PATCH 02/19] Potential fix for code scanning alert no. 11: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../hosting/fastapi/jwt_authorization_middleware.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py index 676cbbe9..19fad680 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -2,7 +2,7 @@ from typing import Callable from fastapi import Request, HTTPException, Depends from fastapi.responses import JSONResponse - +import logging from microsoft_agents.hosting.core.authorization import ( AgentAuthConfiguration, JwtTokenValidator, @@ -138,8 +138,8 @@ async def __call__(self, request: Request, call_next): claims = token_validator.validate_token(token) request.state.claims_identity = claims except ValueError as e: - print(f"JWT validation error: {e}") - return JSONResponse({"error": str(e)}, status_code=401) + logging.warning(f"JWT validation error: {e}") + return JSONResponse({"error": "Invalid token or authentication failed."}, status_code=401) else: return JSONResponse( {"error": "Invalid authorization header format"}, From 0a6d8953b6a1113012be617d07e071d4a8eb22d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Thu, 16 Oct 2025 11:13:59 -0700 Subject: [PATCH 03/19] Update libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../hosting/fastapi/app/streaming/streaming_response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py index 1155db0f..73c5e895 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py @@ -320,7 +320,7 @@ async def _drain_queue(self) -> None: self._cancelled = True else: logger.error( - f"Error occurred when sending activity while streaming: {err}" + f"Error occurred when sending activity while streaming: {type(err).__name__}" ) raise finally: From 57eccd8cec6da92cdb9704e6c24e5d111311a554 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 20 Oct 2025 14:49:54 -0700 Subject: [PATCH 04/19] Fastapi integration wip --- DevInstructions.md | 3 +- .../microsoft_agents/__init__.py | 1 - .../microsoft_agents/hosting/__init__.py | 1 - .../fastapi/jwt_authorization_middleware.py | 9 +- test_samples/fastapi/README.md | 79 +++++ test_samples/fastapi/authorization_agent.py | 335 ++++++++++++++++++ test_samples/fastapi/empty_agent.py | 69 ++++ test_samples/fastapi/env.TEMPLATE | 19 + test_samples/fastapi/requirements.txt | 12 + test_samples/fastapi/shared/__init__.py | 7 + .../fastapi/shared/github_api_client.py | 116 ++++++ .../fastapi/shared/user_graph_client.py | 31 ++ 12 files changed, 676 insertions(+), 6 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/__init__.py create mode 100644 test_samples/fastapi/README.md create mode 100644 test_samples/fastapi/authorization_agent.py create mode 100644 test_samples/fastapi/empty_agent.py create mode 100644 test_samples/fastapi/env.TEMPLATE create mode 100644 test_samples/fastapi/requirements.txt create mode 100644 test_samples/fastapi/shared/__init__.py create mode 100644 test_samples/fastapi/shared/github_api_client.py create mode 100644 test_samples/fastapi/shared/user_graph_client.py diff --git a/DevInstructions.md b/DevInstructions.md index a2bfd480..7b6b6bf1 100644 --- a/DevInstructions.md +++ b/DevInstructions.md @@ -32,7 +32,8 @@ Any issues should be filed on the [Agents-for-python](https://github.com/microso pip install -e ./libraries/microsoft-agents-hosting-aiohttp/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-hosting-teams/ --config-settings editable_mode=compat pip install -e ./libraries/microsoft-agents-storage-blob/ --config-settings editable_mode=compat - pip install -e ./libraries/microsoft-agents-storage-cosmos/ --config-settings editable_mode=compat + pip install -e ./libraries/microsoft-agents-storage-cosmos/ --config-settings editable_mode=compat + pip install -e ./libraries/microsoft-agents-hosting-fastapi/ --config-settings editable_mode=compat ``` 1. Setup the dev dependencies for python. In the terminal, at the project root, run: ``` diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/__init__.py deleted file mode 100644 index eee2b9b5..00000000 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Microsoft Agents packages diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/__init__.py deleted file mode 100644 index 7275ee7b..00000000 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Microsoft Agents Hosting packages diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py index 19fad680..8f2441d5 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -1,9 +1,9 @@ import functools from typing import Callable -from fastapi import Request, HTTPException, Depends +from fastapi import Request, HTTPException from fastapi.responses import JSONResponse import logging -from microsoft_agents.hosting.core.authorization import ( +from microsoft_agents.hosting.core import ( AgentAuthConfiguration, JwtTokenValidator, ) @@ -139,7 +139,10 @@ async def __call__(self, request: Request, call_next): request.state.claims_identity = claims except ValueError as e: logging.warning(f"JWT validation error: {e}") - return JSONResponse({"error": "Invalid token or authentication failed."}, status_code=401) + return JSONResponse( + {"error": "Invalid token or authentication failed."}, + status_code=401, + ) else: return JSONResponse( {"error": "Invalid authorization header format"}, diff --git a/test_samples/fastapi/README.md b/test_samples/fastapi/README.md new file mode 100644 index 00000000..c6a5661b --- /dev/null +++ b/test_samples/fastapi/README.md @@ -0,0 +1,79 @@ +# FastAPI Agent Samples + +This folder contains FastAPI equivalents of the agent samples from the `app_style` folder. + +## Samples + +### 1. empty_agent.py +A simple echo agent that responds to messages and provides basic help functionality. This is the FastAPI equivalent of `emtpy_agent.py`. + +**Features:** +- Basic message echoing +- Welcome message for new conversation members +- Help command + +### 2. authorization_agent.py +A more complex agent that demonstrates user authentication and authorization using Microsoft Graph and GitHub APIs. This is the FastAPI equivalent of `authorization_agent.py`. + +**Features:** +- User authentication with Microsoft Graph and GitHub +- Profile information retrieval +- OAuth flow handling +- Authentication status checking +- Sign-out functionality + +## Setup + +1. **Install dependencies:** + ```bash + pip install -r requirements.txt + ``` + +2. **Install Microsoft Agents libraries:** + ```bash + # From the root of the repository + pip install -e libraries/microsoft-agents-hosting-fastapi + pip install -e libraries/microsoft-agents-hosting-core + pip install -e libraries/microsoft-agents-authentication-msal + pip install -e libraries/microsoft-agents-activity + ``` + +3. **Configure environment variables:** + - Copy `env.TEMPLATE` to `.env` + - Fill in the required configuration values + +## Running the samples + +### Empty Agent +```bash +python empty_agent.py +``` + +### Authorization Agent +```bash +python authorization_agent.py +``` + +Both agents will start on `http://localhost:3978` by default. You can change the port by setting the `PORT` environment variable. + +## Key Differences from aiohttp samples + +1. **Framework**: Uses FastAPI instead of aiohttp +2. **Server startup**: Uses uvicorn instead of aiohttp's run_app +3. **Routing**: Uses FastAPI decorators instead of aiohttp router +4. **Middleware**: Uses FastAPI dependency injection for JWT authorization +5. **Request handling**: Uses FastAPI's Request object and dependency system + +## API Endpoints + +Both samples expose the following endpoints: + +- `POST /api/messages` - Main endpoint for processing bot messages +- `GET /api/messages` - Health check endpoint + +## Dependencies + +The samples use the `microsoft-agents-hosting-fastapi` library which provides: +- `CloudAdapter` - FastAPI-compatible adapter for processing activities +- `start_agent_process` - Function to handle incoming requests +- `jwt_authorization_dependency` - FastAPI dependency for JWT authentication \ No newline at end of file diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py new file mode 100644 index 00000000..ad6120ef --- /dev/null +++ b/test_samples/fastapi/authorization_agent.py @@ -0,0 +1,335 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ, path +import re +import sys +import traceback + +import uvicorn +from dotenv import load_dotenv +from fastapi import FastAPI, Request, Depends +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + TurnState, + TurnContext, + MessageFactory, + MemoryStorage, +) +from microsoft_agents.activity import load_configuration_from_env, ActivityTypes +from microsoft_agents.hosting.fastapi import ( + CloudAdapter, + start_agent_process, + jwt_authorization_dependency, +) +from microsoft_agents.authentication.msal import MsalConnectionManager + +# Import shared utilities +from shared import GraphClient, GitHubClient + +load_dotenv(path.join(path.dirname(__file__), ".env")) + +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + +# Create FastAPI app +app = FastAPI(title="Authorization Agent Sample", version="1.0.0") + + +@AGENT_APP.message(re.compile(r"^(status|auth status|check status)", re.IGNORECASE)) +async def status(context: TurnContext, state: TurnState) -> bool: + """ + Internal method to check authorization status for all configured handlers. + Returns True if at least one handler has a valid token. + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return False + + try: + # Check status for each auth handler + status_messages = [] + has_valid_token = False + + for handler_id in AGENT_APP.auth._auth_handlers.keys(): + try: + token_response = await AGENT_APP.auth.get_token(context, handler_id) + if token_response and token_response.token: + status_messages.append(f"✅ {handler_id}: Connected") + has_valid_token = True + else: + status_messages.append(f"❌ {handler_id}: Not connected") + except Exception as e: + status_messages.append(f"❌ {handler_id}: Error - {str(e)}") + + status_text = "Authorization Status:\n" + "\n".join(status_messages) + await context.send_activity(MessageFactory.text(status_text)) + return has_valid_token + + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error checking status: {str(e)}") + ) + return False + + +@AGENT_APP.message(re.compile(r"^(logout|signout|sign out)", re.IGNORECASE)) +async def sign_out( + context: TurnContext, state: TurnState, handler_id: str = None +) -> bool: + """ + Internal method to sign out from the specified handler or all handlers. + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return False + + try: + await AGENT_APP.auth.sign_out(context, state, handler_id) + if handler_id: + await context.send_activity( + MessageFactory.text(f"Successfully signed out from {handler_id}.") + ) + else: + await context.send_activity( + MessageFactory.text("Successfully signed out from all services.") + ) + return True + except Exception as e: + await context.send_activity(MessageFactory.text(f"Error signing out: {str(e)}")) + return False + + +@AGENT_APP.message( + re.compile(r"^(me|profile)$", re.IGNORECASE), auth_handlers=["GRAPH"] +) +async def profile_request(context: TurnContext, state: TurnState) -> dict: + """ + Internal method to get user profile information using the specified handler. + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return None + + try: + # token_to_exchange = await AGENT_APP.auth.get_token(context, "GRAPH") + token_response = await AGENT_APP.auth.exchange_token( + context, scopes=["User.Read", "email"], auth_handler_id="GRAPH" + ) + if not token_response or not token_response.token: + await context.send_activity( + MessageFactory.text( + f"Not authenticated with Graph. Please sign in first." + ) + ) + return None + + # TODO: Implement actual profile request using the token + # This would require making HTTP requests to the Graph API or other services + # For now, return a placeholder + profile_info = await GraphClient.get_me(token_response.token) + + profile_text = f"Profile Information:\nName: {profile_info['displayName']}\nEmail: {profile_info['mail']}\nID: {profile_info['id']}" + await context.send_activity(MessageFactory.text(profile_text)) + return profile_info + + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error getting profile: {str(e)}") + ) + return None + + +@AGENT_APP.message( + re.compile(r"^(github profile|gh profile)$", re.IGNORECASE), + auth_handlers=["GITHUB"], +) +async def profile_github(context: TurnContext, state: TurnState) -> dict: + """ + Internal method to get GitHub profile information. + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return None + + try: + token_response = await AGENT_APP.auth.get_token(context, "GITHUB") + if not token_response or not token_response.token: + await context.send_activity( + MessageFactory.text( + f"Not authenticated with Github. Please sign in first." + ) + ) + + profile_info = await GitHubClient.get_current_profile(token_response.token) + profile_text = ( + f"GitHub Profile Information:\n" + f"Name: {profile_info['displayName']}\n" + f"Email: {profile_info['mail']}\n" + f"Username: {profile_info['givenName']}\n" + ) + + await context.send_activity(MessageFactory.text(profile_text)) + return profile_info + + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error during sign-in: {str(e)}") + ) + return None + + +@AGENT_APP.message(re.compile(r"^(prs|pull requests)$", re.IGNORECASE)) +async def pull_requests( + context: TurnContext, state: TurnState, handler_id: str = "github" +) -> list: + """ + Internal method to get pull requests using the specified handler (typically GitHub). + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return [] + + try: + token_response = await AGENT_APP.auth.get_token(context, handler_id) + if not token_response or not token_response.token: + await context.send_activity( + MessageFactory.text( + f"Not authenticated with {handler_id}. Please sign in first." + ) + ) + return [] + + # TODO: Implement actual GitHub API request using the token + # This would require making HTTP requests to the GitHub API + # For now, return placeholder data + pull_requests = [ + {"title": "Fix authentication bug", "number": 123, "state": "open"}, + {"title": "Add new feature", "number": 124, "state": "open"}, + {"title": "Update documentation", "number": 125, "state": "closed"}, + ] + + pr_text = "Pull Requests:\n" + "\n".join( + [f"#{pr['number']}: {pr['title']} ({pr['state']})" for pr in pull_requests] + ) + await context.send_activity(MessageFactory.text(pr_text)) + return pull_requests + + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error getting pull requests: {str(e)}") + ) + return [] + + +@AGENT_APP.message( + re.compile(r"^(all profiles|all)", re.IGNORECASE), auth_handlers=["GRAPH", "GITHUB"] +) +async def all_profiles(context: TurnContext, state: TurnState) -> dict: + """ + Internal method to get profiles from all configured handlers. + """ + try: + await profile_request(context, state) + await profile_github(context, state) + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error retrieving profiles: {str(e)}") + ) + return None + + +@AGENT_APP.activity(ActivityTypes.invoke) +async def invoke(context: TurnContext, state: TurnState) -> str: + """ + Internal method to process template expansion or function invocation. + """ + await AGENT_APP.auth.begin_or_continue_flow(context, state) + + +@AGENT_APP.on_sign_in_success +async def handle_sign_in_success( + context: TurnContext, state: TurnState, handler_id: str = None +) -> bool: + """ + Internal method to handle successful sign-in events. + """ + await context.send_activity( + MessageFactory.text( + f"Successfully signed in to {handler_id or 'service'}. You can now use authorized features." + ) + ) + + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the Authorization Agent! " + "You can use commands like 'login', 'status', 'profile', 'prs', or 'logout'. " + "For OAuth flows, enter the 6-digit verification code when prompted." + ) + return True + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, state: TurnState): + await context.send_activity(f"You said: {context.activity.text}") + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") + + +# FastAPI routes +@app.post("/api/messages") +async def messages_handler( + request: Request, + claims_identity=Depends(jwt_authorization_dependency), +): + """Main endpoint for processing bot messages.""" + # Store claims identity in request state for CloudAdapter to use + request.state.claims_identity = claims_identity + + return await start_agent_process( + request, + AGENT_APP, + AGENT_APP.adapter, + ) + + +@app.get("/api/messages") +async def messages_get(): + """Health check endpoint.""" + return {"status": "OK"} + + +if __name__ == "__main__": + port = int(environ.get("PORT", 3978)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/test_samples/fastapi/empty_agent.py b/test_samples/fastapi/empty_agent.py new file mode 100644 index 00000000..c8cf4f81 --- /dev/null +++ b/test_samples/fastapi/empty_agent.py @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import uvicorn +from fastapi import FastAPI, Request, Depends +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnState, + TurnContext, + MemoryStorage, +) +from microsoft_agents.hosting.fastapi import ( + CloudAdapter, + start_agent_process, + jwt_authorization_dependency, +) + +# Create the agent application +AGENT_APP = AgentApplication[TurnState](storage=MemoryStorage(), adapter=CloudAdapter()) + +# Create FastAPI app +app = FastAPI(title="Empty Agent Sample", version="1.0.0") + + +# Agent handlers +async def _help(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the Echo Agent sample 🚀. " + "Type /help for help or send a message to see the echo feature in action." + ) + + +AGENT_APP.conversation_update("membersAdded")(_help) +AGENT_APP.message("/help")(_help) + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _): + await context.send_activity(f"you said: {context.activity.text}") + + +# FastAPI routes +@app.post("/api/messages") +async def messages_handler( + request: Request, + claims_identity=Depends(jwt_authorization_dependency), +): + """Main endpoint for processing bot messages.""" + # Store claims identity in request state for CloudAdapter to use + request.state.claims_identity = claims_identity + + return await start_agent_process( + request, + AGENT_APP, + AGENT_APP.adapter, + ) + + +@app.get("/api/messages") +async def messages_get(): + """Health check endpoint.""" + return {"status": "OK"} + + +if __name__ == "__main__": + import os + + port = int(os.environ.get("PORT", 3978)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/test_samples/fastapi/env.TEMPLATE b/test_samples/fastapi/env.TEMPLATE new file mode 100644 index 00000000..f9adb9ac --- /dev/null +++ b/test_samples/fastapi/env.TEMPLATE @@ -0,0 +1,19 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id + +CONNECTIONS__MCS__SETTINGS__CLIENTID=client-id +CONNECTIONS__MCS__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__MCS__SETTINGS__TENANTID=tenant-id + +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__OBOCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GITHUB__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GITHUB__SETTINGS__OBOCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__MCS__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__MCS__SETTINGS__OBOCONNECTIONNAME=connection-name + +COPILOTSTUDIOAGENT__ENVIRONMENTID=environment-id +COPILOTSTUDIOAGENT__SCHEMANAME=schema-name +COPILOTSTUDIOAGENT__TENANTID=tenant-id +COPILOTSTUDIOAGENT__AGENTAPPID=agent-app-id \ No newline at end of file diff --git a/test_samples/fastapi/requirements.txt b/test_samples/fastapi/requirements.txt new file mode 100644 index 00000000..555a87ea --- /dev/null +++ b/test_samples/fastapi/requirements.txt @@ -0,0 +1,12 @@ +# FastAPI sample requirements +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +python-dotenv>=1.0.0 +aiohttp>=3.8.0 + +# Microsoft Agents libraries +# These should be installed from the local libraries folder +# microsoft-agents-hosting-fastapi +# microsoft-agents-hosting-core +# microsoft-agents-authentication-msal +# microsoft-agents-activity \ No newline at end of file diff --git a/test_samples/fastapi/shared/__init__.py b/test_samples/fastapi/shared/__init__.py new file mode 100644 index 00000000..1a56a68d --- /dev/null +++ b/test_samples/fastapi/shared/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .github_api_client import GitHubClient +from .user_graph_client import GraphClient + +__all__ = ["GitHubClient", "GraphClient"] diff --git a/test_samples/fastapi/shared/github_api_client.py b/test_samples/fastapi/shared/github_api_client.py new file mode 100644 index 00000000..6ecc4cf7 --- /dev/null +++ b/test_samples/fastapi/shared/github_api_client.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiohttp +from typing import List, Dict, Any + + +class PullRequest: + """ + Represents a GitHub pull request. + """ + + def __init__(self, id: int, title: str, url: str): + self.id = id + self.title = title + self.url = url + + def to_dict(self) -> Dict[str, Any]: + return {"id": self.id, "title": self.title, "url": self.url} + + +class GitHubClient: + """ + A simple GitHub API client using aiohttp. + """ + + @staticmethod + async def get_current_profile(token: str) -> Dict[str, Any]: + """ + Get information about the current authenticated user. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgentsSDKDemo", + "Content-Type": "application/json", + } + async with session.get( + "https://api.github.com/user", headers=headers + ) as response: + if response.status == 200: + data = await response.json() + return { + "displayName": data.get("name", ""), + "mail": data.get("html_url", ""), + "jobTitle": "", + "givenName": data.get("login", ""), + "surname": "", + "imageUri": data.get("avatar_url", ""), + } + else: + error_text = await response.text() + raise Exception( + f"Error fetching user profile: {response.status} - {error_text}" + ) + + @staticmethod + async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullRequest]: + """ + Get pull requests for a specific repository. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgentsSDKDemo", + "Content-Type": "application/json", + } + url = f"https://api.github.com/repos/{owner}/{repo}/pulls" + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return [ + PullRequest( + id=pr.get("id"), + title=pr.get("title"), + url=pr.get("html_url"), + ) + for pr in data + ] + else: + error_text = await response.text() + raise Exception( + f"Error fetching pull requests: {response.status} - {error_text}" + ) + + @staticmethod + async def get_user_pull_requests(token: str) -> List[PullRequest]: + """ + Get pull requests created by the authenticated user across all repositories. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgentsSDKDemo", + "Content-Type": "application/json", + } + url = "https://api.github.com/search/issues?q=type:pr+author:@me" + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return [ + PullRequest( + id=pr.get("id"), + title=pr.get("title"), + url=pr.get("html_url"), + ) + for pr in data.get("items", []) + ] + else: + error_text = await response.text() + raise Exception( + f"Error fetching user pull requests: {response.status} - {error_text}" + ) diff --git a/test_samples/fastapi/shared/user_graph_client.py b/test_samples/fastapi/shared/user_graph_client.py new file mode 100644 index 00000000..05bbe57f --- /dev/null +++ b/test_samples/fastapi/shared/user_graph_client.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiohttp + + +class GraphClient: + """ + A simple Microsoft Graph client using aiohttp. + """ + + @staticmethod + async def get_me(token: str): + """ + Get information about the current user. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + async with session.get( + f"https://graph.microsoft.com/v1.0/me", headers=headers + ) as response: + if response.status == 200: + return await response.json() + else: + error_text = await response.text() + raise Exception( + f"Error from Graph API: {response.status} - {error_text}" + ) From 3d4c0fed9fc11720fa4e1571efff1a0c988bde2a Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 21 Oct 2025 17:01:01 -0700 Subject: [PATCH 05/19] Fastapi integration wip, emtpy agent working --- .../hosting/fastapi/cloud_adapter.py | 1 - .../fastapi/jwt_authorization_middleware.py | 180 +++++------------- test_samples/fastapi/empty_agent.py | 6 +- 3 files changed, 48 insertions(+), 139 deletions(-) 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 74f75e2f..3383c793 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 @@ -2,7 +2,6 @@ # Licensed under the MIT License. from traceback import format_exc from typing import Optional -import json from fastapi import Request, Response, HTTPException from fastapi.responses import JSONResponse diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py index 8f2441d5..5e19279a 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -1,163 +1,75 @@ -import functools -from typing import Callable -from fastapi import Request, HTTPException +from fastapi import Request from fastapi.responses import JSONResponse import logging +from starlette.types import ASGIApp, Receive, Scope, Send from microsoft_agents.hosting.core import ( AgentAuthConfiguration, JwtTokenValidator, ) -async def jwt_authorization_dependency(request: Request): - """ - FastAPI dependency for JWT authorization. - - Usage: - @app.post("/api/messages") - async def messages(request: Request, _: None = Depends(jwt_authorization_dependency)): - # Your handler code here - """ - # Get auth configuration from app state - auth_config: AgentAuthConfiguration = getattr( - request.app.state, "agent_configuration", None - ) - if not auth_config: - raise HTTPException(status_code=500, detail="Agent configuration not found") - - token_validator = JwtTokenValidator(auth_config) - auth_header = request.headers.get("Authorization") +logger = logging.getLogger(__name__) - if auth_header: - # Extract the token from the Authorization header - parts = auth_header.split(" ") - if len(parts) != 2 or parts[0].lower() != "bearer": - raise HTTPException( - status_code=401, detail="Invalid authorization header format" - ) - token = parts[1] - try: - claims = token_validator.validate_token(token) - request.state.claims_identity = claims - except ValueError as e: - print(f"JWT validation error: {e}") - raise HTTPException(status_code=401, detail=str(e)) - else: - if not auth_config or not auth_config.CLIENT_ID: - # TODO: Refine anonymous strategy - request.state.claims_identity = token_validator.get_anonymous_claims() - else: - raise HTTPException( - status_code=401, detail="Authorization header not found" - ) +class JwtAuthorizationMiddleware: + """Starlette-compatible ASGI middleware for JWT authorization. + Usage: + from fastapi import FastAPI -def jwt_authorization_decorator(func: Callable): + app = FastAPI() + app.add_middleware(JwtAuthorizationMiddleware) """ - Decorator for JWT authorization on individual route functions. - Usage: - @jwt_authorization_decorator - async def messages(request: Request): - # Your handler code here - """ + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + if scope["type"] == "lifespan": + await self.app(scope, receive, send) + return - @functools.wraps(func) - async def wrapper(request: Request, *args, **kwargs): - # Get auth configuration from app state + app = scope.get("app") + state = getattr(app, "state", None) if app else None auth_config: AgentAuthConfiguration = getattr( - request.app.state, "agent_configuration", None + state, "agent_configuration", None ) - if not auth_config: - raise HTTPException(status_code=500, detail="Agent configuration not found") + request = Request(scope, receive=receive) token_validator = JwtTokenValidator(auth_config) auth_header = request.headers.get("Authorization") if auth_header: - # Extract the token from the Authorization header parts = auth_header.split(" ") - if len(parts) != 2 or parts[0].lower() != "bearer": - raise HTTPException( - status_code=401, detail="Invalid authorization header format" + if len(parts) == 2 and parts[0].lower() == "bearer": + token = parts[1] + try: + claims = token_validator.validate_token(token) + request.state.claims_identity = claims + except ValueError as e: + logger.warning("JWT validation error: %s", e) + response = JSONResponse( + {"error": "Invalid token or authentication failed."}, + status_code=401, + ) + await response(scope, receive, send) + return + else: + response = JSONResponse( + {"error": "Invalid authorization header format"}, + status_code=401, ) - - token = parts[1] - try: - claims = token_validator.validate_token(token) - request.state.claims_identity = claims - except ValueError as e: - print(f"JWT validation error: {e}") - raise HTTPException(status_code=401, detail=str(e)) + await response(scope, receive, send) + return else: - if not auth_config.CLIENT_ID: - # TODO: Refine anonymous strategy + if not auth_config or not auth_config.CLIENT_ID: request.state.claims_identity = token_validator.get_anonymous_claims() else: - raise HTTPException( - status_code=401, detail="Authorization header not found" + response = JSONResponse( + {"error": "Authorization header not found"}, + status_code=401, ) + await response(scope, receive, send) + return - return await func(request, *args, **kwargs) - - return wrapper - - -class JwtAuthorizationMiddleware: - """ - Middleware class for JWT authorization in FastAPI. - - Usage: - from fastapi import FastAPI - from fastapi.middleware.base import BaseHTTPMiddleware - - app = FastAPI() - app.add_middleware(BaseHTTPMiddleware, dispatch=JwtAuthorizationMiddleware()) - """ - - def __init__(self): - pass - - async def __call__(self, request: Request, call_next): - # Get auth configuration from app state - auth_config: AgentAuthConfiguration = getattr( - request.app.state, "agent_configuration", None - ) - - if auth_config: - token_validator = JwtTokenValidator(auth_config) - auth_header = request.headers.get("Authorization") - - if auth_header: - # Extract the token from the Authorization header - parts = auth_header.split(" ") - if len(parts) == 2 and parts[0].lower() == "bearer": - token = parts[1] - try: - claims = token_validator.validate_token(token) - request.state.claims_identity = claims - except ValueError as e: - logging.warning(f"JWT validation error: {e}") - return JSONResponse( - {"error": "Invalid token or authentication failed."}, - status_code=401, - ) - else: - return JSONResponse( - {"error": "Invalid authorization header format"}, - status_code=401, - ) - else: - if not auth_config.CLIENT_ID: - # TODO: Refine anonymous strategy - request.state.claims_identity = ( - token_validator.get_anonymous_claims() - ) - else: - return JSONResponse( - {"error": "Authorization header not found"}, status_code=401 - ) - - response = await call_next(request) - return response + await self.app(scope, receive, send) diff --git a/test_samples/fastapi/empty_agent.py b/test_samples/fastapi/empty_agent.py index c8cf4f81..2d079e6c 100644 --- a/test_samples/fastapi/empty_agent.py +++ b/test_samples/fastapi/empty_agent.py @@ -12,7 +12,7 @@ from microsoft_agents.hosting.fastapi import ( CloudAdapter, start_agent_process, - jwt_authorization_dependency, + JwtAuthorizationMiddleware, ) # Create the agent application @@ -20,6 +20,7 @@ # Create FastAPI app app = FastAPI(title="Empty Agent Sample", version="1.0.0") +app.add_middleware(JwtAuthorizationMiddleware) # Agent handlers @@ -43,11 +44,8 @@ async def on_message(context: TurnContext, _): @app.post("/api/messages") async def messages_handler( request: Request, - claims_identity=Depends(jwt_authorization_dependency), ): """Main endpoint for processing bot messages.""" - # Store claims identity in request state for CloudAdapter to use - request.state.claims_identity = claims_identity return await start_agent_process( request, From 619dd4a9f1ef73088b39857046490caa46c5c22d Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 24 Oct 2025 15:30:32 -0700 Subject: [PATCH 06/19] Fastapi integration wip, auth agent bugfixing --- .../hosting/fastapi/__init__.py | 2 -- .../fastapi/jwt_authorization_middleware.py | 6 ++++-- test_samples/fastapi/authorization_agent.py | 18 ++++++++++-------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py index 1ffcd7c5..62787062 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py @@ -3,8 +3,6 @@ from .channel_service_route_table import channel_service_route_table from .cloud_adapter import CloudAdapter from .jwt_authorization_middleware import ( - jwt_authorization_dependency, - jwt_authorization_decorator, JwtAuthorizationMiddleware, ) from .app.streaming import ( diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py index 5e19279a..44abee1a 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -44,7 +44,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): if len(parts) == 2 and parts[0].lower() == "bearer": token = parts[1] try: - claims = token_validator.validate_token(token) + claims = await token_validator.validate_token(token) request.state.claims_identity = claims except ValueError as e: logger.warning("JWT validation error: %s", e) @@ -63,7 +63,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): return else: if not auth_config or not auth_config.CLIENT_ID: - request.state.claims_identity = token_validator.get_anonymous_claims() + request.state.claims_identity = ( + await token_validator.get_anonymous_claims() + ) else: response = JSONResponse( {"error": "Authorization header not found"}, diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py index ad6120ef..203a794f 100644 --- a/test_samples/fastapi/authorization_agent.py +++ b/test_samples/fastapi/authorization_agent.py @@ -21,7 +21,7 @@ from microsoft_agents.hosting.fastapi import ( CloudAdapter, start_agent_process, - jwt_authorization_dependency, + JwtAuthorizationMiddleware, ) from microsoft_agents.authentication.msal import MsalConnectionManager @@ -41,9 +41,6 @@ storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config ) -# Create FastAPI app -app = FastAPI(title="Authorization Agent Sample", version="1.0.0") - @AGENT_APP.message(re.compile(r"^(status|auth status|check status)", re.IGNORECASE)) async def status(context: TurnContext, state: TurnState) -> bool: @@ -62,7 +59,7 @@ async def status(context: TurnContext, state: TurnState) -> bool: status_messages = [] has_valid_token = False - for handler_id in AGENT_APP.auth._auth_handlers.keys(): + for handler_id in AGENT_APP.auth._handlers.keys(): try: token_response = await AGENT_APP.auth.get_token(context, handler_id) if token_response and token_response.token: @@ -307,15 +304,20 @@ async def on_error(context: TurnContext, error: Exception): await context.send_activity("The bot encountered an error or bug.") +# Create FastAPI app +app = FastAPI(title="Authorization Agent Sample", version="1.0.0") +app.state.agent_configuration = ( + CONNECTION_MANAGER.get_default_connection_configuration() +) +app.add_middleware(JwtAuthorizationMiddleware) + + # FastAPI routes @app.post("/api/messages") async def messages_handler( request: Request, - claims_identity=Depends(jwt_authorization_dependency), ): """Main endpoint for processing bot messages.""" - # Store claims identity in request state for CloudAdapter to use - request.state.claims_identity = claims_identity return await start_agent_process( request, From e7f0cad07e8e4c1d20d72dda532aedd00b38b7cb Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 24 Oct 2025 15:33:22 -0700 Subject: [PATCH 07/19] Revert "1. TypingIndicator Concurrency & Argument fixes (#187)" This reverts commit 3024b7c401eb895f5915c367b57cc92fe4f8cb9c. --- .../hosting/core/app/agent_application.py | 13 ++-- .../hosting/core/app/typing_indicator.py | 78 +++++++------------ .../hosting_core/app/test_typing_indicator.py | 76 ------------------ 3 files changed, 34 insertions(+), 133 deletions(-) delete mode 100644 tests/hosting_core/app/test_typing_indicator.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index ac777846..b09605f1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -93,6 +93,7 @@ def __init__( :param kwargs: Additional configuration parameters. :type kwargs: Any """ + self.typing = TypingIndicator() self._route_list = _RouteList[StateT]() configuration = kwargs @@ -658,12 +659,9 @@ async def on_turn(self, context: TurnContext): await self._start_long_running_call(context, self._on_turn) async def _on_turn(self, context: TurnContext): - typing = None try: if context.activity.type != ActivityTypes.typing: - if self._options.start_typing_timer: - typing = TypingIndicator() - await typing.start(context) + await self._start_typing(context) self._remove_mentions(context) @@ -711,8 +709,11 @@ async def _on_turn(self, context: TurnContext): ) await self._on_error(context, err) finally: - if typing: - await typing.stop() + self.typing.stop() + + async def _start_typing(self, context: TurnContext): + if self._options.start_typing_timer: + await self.typing.start(context) def _remove_mentions(self, context: TurnContext): if ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index 24b3c0a0..b33c568f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -4,9 +4,9 @@ """ from __future__ import annotations -import asyncio import logging +from threading import Timer from typing import Optional from microsoft_agents.hosting.core import TurnContext @@ -20,60 +20,36 @@ class TypingIndicator: Encapsulates the logic for sending "typing" activity to the user. """ - def __init__(self, intervalSeconds=1) -> None: - self._intervalSeconds = intervalSeconds - self._task: Optional[asyncio.Task] = None - self._running: bool = False - self._lock = asyncio.Lock() + _interval: int + _timer: Optional[Timer] = None - async def start(self, context: TurnContext) -> None: - async with self._lock: - if self._running: - return + def __init__(self, interval=1000) -> None: + self._interval = interval - logger.debug( - f"Starting typing indicator with interval: {self._intervalSeconds} seconds" - ) - self._running = True - self._task = asyncio.create_task(self._typing_loop(context)) + async def start(self, context: TurnContext) -> None: + if self._timer is not None: + return - async def stop(self) -> None: - async with self._lock: - if not self._running: - return + logger.debug(f"Starting typing indicator with interval: {self._interval} ms") + func = self._on_timer(context) + self._timer = Timer(self._interval, func) + self._timer.start() + await func() + def stop(self) -> None: + if self._timer: logger.debug("Stopping typing indicator") - self._running = False - task = self._task - self._task = None + self._timer.cancel() + self._timer = None - # Cancel outside the lock to avoid blocking - if task and not task.done(): - task.cancel() + def _on_timer(self, context: TurnContext): + async def __call__(): try: - await task - except asyncio.CancelledError: - pass - - async def _typing_loop(self, context: TurnContext): - """Continuously send typing indicators at the specified interval.""" - try: - while True: - # Check running status under lock - async with self._lock: - if not self._running: - break - - try: - logger.debug("Sending typing activity") - await context.send_activity(Activity(type=ActivityTypes.typing)) - except Exception as e: - logger.error(f"Error sending typing activity: {e}") - async with self._lock: - self._running = False - break - - await asyncio.sleep(self._intervalSeconds) - except asyncio.CancelledError: - logger.debug("Typing indicator loop cancelled") - raise + logger.debug("Sending typing activity") + await context.send_activity(Activity(type=ActivityTypes.typing)) + except Exception as e: + # TODO: Improve when adding logging + logger.error(f"Error sending typing activity: {e}") + self.stop() + + return __call__ diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py deleted file mode 100644 index 22a09c8f..00000000 --- a/tests/hosting_core/app/test_typing_indicator.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio - -import pytest - -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core.app.typing_indicator import TypingIndicator - - -class StubTurnContext: - """Test double that tracks sent activities.""" - - def __init__(self, should_raise: bool = False) -> None: - self.sent_activities = [] - self.should_raise = should_raise - - async def send_activity(self, activity: Activity): - if self.should_raise: - raise RuntimeError("send_activity failure") - self.sent_activities.append(activity) - return None - - -@pytest.mark.asyncio -async def test_start_sends_typing_activity(): - context = StubTurnContext() - indicator = TypingIndicator(intervalSeconds=0.01) - - await indicator.start(context) - await asyncio.sleep(0.03) - await indicator.stop() - - assert len(context.sent_activities) >= 1 - assert all(activity.type == ActivityTypes.typing for activity in context.sent_activities) - - -@pytest.mark.asyncio -async def test_start_is_idempotent(): - context = StubTurnContext() - indicator = TypingIndicator(intervalSeconds=0.01) - - await indicator.start(context) - first_task = indicator._task # noqa: SLF001 - accessing for test verification - - await indicator.start(context) - second_task = indicator._task # noqa: SLF001 - - assert first_task is second_task - - await indicator.stop() - - -@pytest.mark.asyncio -async def test_stop_without_start_is_noop(): - indicator = TypingIndicator() - - await indicator.stop() - - assert indicator._task is None # noqa: SLF001 - assert indicator._running is False # noqa: SLF001 - - -@pytest.mark.asyncio -async def test_typing_loop_stops_on_send_error(): - context = StubTurnContext(should_raise=True) - indicator = TypingIndicator(intervalSeconds=0.01) - - await indicator.start(context) - await asyncio.sleep(0.02) - - assert indicator._task is not None # noqa: SLF001 - await asyncio.wait_for(indicator._task, timeout=0.1) # Ensure loop exits - - assert indicator._running is False # noqa: SLF001 - assert indicator._task.done() # noqa: SLF001 - - await indicator.stop() From c72a8eb92fb4b5d01ae64d267c12a4dd6ad82479 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 24 Oct 2025 15:46:38 -0700 Subject: [PATCH 08/19] Fastapi integration tentative, need to validate auth behavior --- test_samples/fastapi/authorization_agent.py | 287 ++++-------------- test_samples/fastapi/shared/__init__.py | 7 +- test_samples/fastapi/shared/cards.py | 97 ++++++ .../fastapi/shared/github_api_client.py | 157 +++------- .../fastapi/shared/user_graph_client.py | 41 +-- 5 files changed, 222 insertions(+), 367 deletions(-) create mode 100644 test_samples/fastapi/shared/cards.py diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py index 203a794f..b13a7529 100644 --- a/test_samples/fastapi/authorization_agent.py +++ b/test_samples/fastapi/authorization_agent.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import logging from os import environ, path import re import sys @@ -25,8 +26,9 @@ ) from microsoft_agents.authentication.msal import MsalConnectionManager -# Import shared utilities -from shared import GraphClient, GitHubClient +from shared import get_current_profile, get_pull_requests, get_user_info, create_profile_card, create_pr_card + +logger = logging.getLogger(__name__) load_dotenv(path.join(path.dirname(__file__), ".env")) @@ -42,268 +44,93 @@ ) -@AGENT_APP.message(re.compile(r"^(status|auth status|check status)", re.IGNORECASE)) +@AGENT_APP.message(re.compile(r"^/(status|auth status|check status)", re.IGNORECASE)) async def status(context: TurnContext, state: TurnState) -> bool: """ Internal method to check authorization status for all configured handlers. Returns True if at least one handler has a valid token. """ - if not AGENT_APP.auth: - await context.send_activity( - MessageFactory.text("Authorization is not configured.") - ) - return False - - try: - # Check status for each auth handler - status_messages = [] - has_valid_token = False - - for handler_id in AGENT_APP.auth._handlers.keys(): - try: - token_response = await AGENT_APP.auth.get_token(context, handler_id) - if token_response and token_response.token: - status_messages.append(f"✅ {handler_id}: Connected") - has_valid_token = True - else: - status_messages.append(f"❌ {handler_id}: Not connected") - except Exception as e: - status_messages.append(f"❌ {handler_id}: Error - {str(e)}") - - status_text = "Authorization Status:\n" + "\n".join(status_messages) - await context.send_activity(MessageFactory.text(status_text)) - return has_valid_token - - except Exception as e: - await context.send_activity( - MessageFactory.text(f"Error checking status: {str(e)}") + await context.send_activity(MessageFactory.text("Welcome to the auto-signin demo")) + tok_graph = await AGENT_APP.auth.get_token(context, "GRAPH") + tok_github = await AGENT_APP.auth.get_token(context, "GITHUB") + status_graph = tok_graph.token is not None + status_github = tok_github.token is not None + await context.send_activity( + MessageFactory.text( + f"Graph status: {'Connected' if status_graph else 'Not connected'}\n" + f"GitHub status: {'Connected' if status_github else 'Not connected'}" ) - return False - + ) -@AGENT_APP.message(re.compile(r"^(logout|signout|sign out)", re.IGNORECASE)) -async def sign_out( - context: TurnContext, state: TurnState, handler_id: str = None -) -> bool: - """ - Internal method to sign out from the specified handler or all handlers. - """ - if not AGENT_APP.auth: - await context.send_activity( - MessageFactory.text("Authorization is not configured.") - ) - return False - try: - await AGENT_APP.auth.sign_out(context, state, handler_id) - if handler_id: - await context.send_activity( - MessageFactory.text(f"Successfully signed out from {handler_id}.") - ) - else: - await context.send_activity( - MessageFactory.text("Successfully signed out from all services.") - ) - return True - except Exception as e: - await context.send_activity(MessageFactory.text(f"Error signing out: {str(e)}")) - return False +@AGENT_APP.message("/logout") +async def logout(context: TurnContext, state: TurnState) -> None: + await AGENT_APP.auth.sign_out(context, "GRAPH") + await AGENT_APP.auth.sign_out(context, "GITHUB") + await context.send_activity(MessageFactory.text("You have been logged out.")) @AGENT_APP.message( - re.compile(r"^(me|profile)$", re.IGNORECASE), auth_handlers=["GRAPH"] + re.compile(r"^/(me|profile)$", re.IGNORECASE), auth_handlers=["GRAPH"] ) -async def profile_request(context: TurnContext, state: TurnState) -> dict: - """ - Internal method to get user profile information using the specified handler. - """ - if not AGENT_APP.auth: - await context.send_activity( - MessageFactory.text("Authorization is not configured.") - ) - return None - - try: - # token_to_exchange = await AGENT_APP.auth.get_token(context, "GRAPH") - token_response = await AGENT_APP.auth.exchange_token( - context, scopes=["User.Read", "email"], auth_handler_id="GRAPH" - ) - if not token_response or not token_response.token: - await context.send_activity( - MessageFactory.text( - f"Not authenticated with Graph. Please sign in first." - ) - ) - return None - - # TODO: Implement actual profile request using the token - # This would require making HTTP requests to the Graph API or other services - # For now, return a placeholder - profile_info = await GraphClient.get_me(token_response.token) - - profile_text = f"Profile Information:\nName: {profile_info['displayName']}\nEmail: {profile_info['mail']}\nID: {profile_info['id']}" - await context.send_activity(MessageFactory.text(profile_text)) - return profile_info - - except Exception as e: +async def profile_request(context: TurnContext, state: TurnState) -> None: + user_token_response = await AGENT_APP.auth.get_token(context, "GRAPH") + if user_token_response and user_token_response is not None: + user_info = await get_user_info(user_token_response.token) + activity = MessageFactory.attachment(create_profile_card(user_info)) + await context.send_activity(activity) + else: await context.send_activity( - MessageFactory.text(f"Error getting profile: {str(e)}") + MessageFactory.text('Token not available. Enter "login" to sign in.') ) - return None @AGENT_APP.message( - re.compile(r"^(github profile|gh profile)$", re.IGNORECASE), - auth_handlers=["GITHUB"], + re.compile(r"^/(prs|pull requests)$", re.IGNORECASE), auth_handlers=["GITHUB"] ) -async def profile_github(context: TurnContext, state: TurnState) -> dict: - """ - Internal method to get GitHub profile information. - """ - if not AGENT_APP.auth: +async def pull_requests(context: TurnContext, state: TurnState) -> None: + user_token_response = await AGENT_APP.auth.get_token(context, "GITHUB") + if user_token_response and user_token_response is not None: + gh_prof = await get_current_profile(user_token_response.token) await context.send_activity( - MessageFactory.text("Authorization is not configured.") + MessageFactory.attachment(create_profile_card(gh_prof)) ) - return None - - try: - token_response = await AGENT_APP.auth.get_token(context, "GITHUB") - if not token_response or not token_response.token: - await context.send_activity( - MessageFactory.text( - f"Not authenticated with Github. Please sign in first." - ) - ) - profile_info = await GitHubClient.get_current_profile(token_response.token) - profile_text = ( - f"GitHub Profile Information:\n" - f"Name: {profile_info['displayName']}\n" - f"Email: {profile_info['mail']}\n" - f"Username: {profile_info['givenName']}\n" + # prs = await get_pull_requests("microsoft", "agents", user_token_response.token) + # as suggested by Copilot, using a public repository without SAML enforcement + prs = await get_pull_requests( + "octocat", "Hello-World", user_token_response.token ) - - await context.send_activity(MessageFactory.text(profile_text)) - return profile_info - - except Exception as e: - await context.send_activity( - MessageFactory.text(f"Error during sign-in: {str(e)}") + for pr in prs: + card = create_pr_card(pr) + await context.send_activity(MessageFactory.attachment(card)) + else: + token_response = await AGENT_APP.auth.begin_or_continue_flow( + context, state, "GITHUB" ) - return None - - -@AGENT_APP.message(re.compile(r"^(prs|pull requests)$", re.IGNORECASE)) -async def pull_requests( - context: TurnContext, state: TurnState, handler_id: str = "github" -) -> list: - """ - Internal method to get pull requests using the specified handler (typically GitHub). - """ - if not AGENT_APP.auth: - await context.send_activity( - MessageFactory.text("Authorization is not configured.") - ) - return [] - - try: - token_response = await AGENT_APP.auth.get_token(context, handler_id) - if not token_response or not token_response.token: + logger.warning(f"GitHub token: {json.dumps(token_response)}") + if token_response and token_response.token is not None: await context.send_activity( - MessageFactory.text( - f"Not authenticated with {handler_id}. Please sign in first." - ) + MessageFactory.text(f"GitHub token length: {len(token_response.token)}") + ) + else: + await context.send_activity( + MessageFactory.text("Failed to obtain GitHub token.") ) - return [] - - # TODO: Implement actual GitHub API request using the token - # This would require making HTTP requests to the GitHub API - # For now, return placeholder data - pull_requests = [ - {"title": "Fix authentication bug", "number": 123, "state": "open"}, - {"title": "Add new feature", "number": 124, "state": "open"}, - {"title": "Update documentation", "number": 125, "state": "closed"}, - ] - - pr_text = "Pull Requests:\n" + "\n".join( - [f"#{pr['number']}: {pr['title']} ({pr['state']})" for pr in pull_requests] - ) - await context.send_activity(MessageFactory.text(pr_text)) - return pull_requests - - except Exception as e: - await context.send_activity( - MessageFactory.text(f"Error getting pull requests: {str(e)}") - ) - return [] - - -@AGENT_APP.message( - re.compile(r"^(all profiles|all)", re.IGNORECASE), auth_handlers=["GRAPH", "GITHUB"] -) -async def all_profiles(context: TurnContext, state: TurnState) -> dict: - """ - Internal method to get profiles from all configured handlers. - """ - try: - await profile_request(context, state) - await profile_github(context, state) - except Exception as e: - await context.send_activity( - MessageFactory.text(f"Error retrieving profiles: {str(e)}") - ) - return None @AGENT_APP.activity(ActivityTypes.invoke) -async def invoke(context: TurnContext, state: TurnState) -> str: - """ - Internal method to process template expansion or function invocation. - """ - await AGENT_APP.auth.begin_or_continue_flow(context, state) +async def invoke(context: TurnContext, state: TurnState) -> None: + await context.send_activity(MessageFactory.text("Invoke activity received.")) -@AGENT_APP.on_sign_in_success -async def handle_sign_in_success( - context: TurnContext, state: TurnState, handler_id: str = None -) -> bool: - """ - Internal method to handle successful sign-in events. - """ +@AGENT_APP.activity(ActivityTypes.message) +async def message(context: TurnContext, state: TurnState) -> None: await context.send_activity( - MessageFactory.text( - f"Successfully signed in to {handler_id or 'service'}. You can now use authorized features." - ) + MessageFactory.text(f"You said: {context.activity.text}") ) -@AGENT_APP.conversation_update("membersAdded") -async def on_members_added(context: TurnContext, _state: TurnState): - await context.send_activity( - "Welcome to the Authorization Agent! " - "You can use commands like 'login', 'status', 'profile', 'prs', or 'logout'. " - "For OAuth flows, enter the 6-digit verification code when prompted." - ) - return True - - -@AGENT_APP.activity("message") -async def on_message(context: TurnContext, state: TurnState): - await context.send_activity(f"You said: {context.activity.text}") - - -@AGENT_APP.error -async def on_error(context: TurnContext, error: Exception): - # This check writes out errors to console log .vs. app insights. - # NOTE: In production environment, you should consider logging this to Azure - # application insights. - print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) - traceback.print_exc() - - # Send a message to the user - await context.send_activity("The bot encountered an error or bug.") - - # Create FastAPI app app = FastAPI(title="Authorization Agent Sample", version="1.0.0") app.state.agent_configuration = ( diff --git a/test_samples/fastapi/shared/__init__.py b/test_samples/fastapi/shared/__init__.py index 1a56a68d..5971e2f2 100644 --- a/test_samples/fastapi/shared/__init__.py +++ b/test_samples/fastapi/shared/__init__.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .github_api_client import GitHubClient -from .user_graph_client import GraphClient +from .github_api_client import PullRequest, get_current_profile, get_pull_requests +from .user_graph_client import get_user_info +from .cards import create_profile_card, create_pr_card -__all__ = ["GitHubClient", "GraphClient"] +__all__ = ["PullRequest", "get_current_profile", "get_pull_requests", "get_user_info", "create_profile_card", "create_pr_card", "get_user_info"] diff --git a/test_samples/fastapi/shared/cards.py b/test_samples/fastapi/shared/cards.py new file mode 100644 index 00000000..8788e22a --- /dev/null +++ b/test_samples/fastapi/shared/cards.py @@ -0,0 +1,97 @@ +from microsoft_agents.hosting.core import CardFactory + +def create_profile_card(profile): + return CardFactory.adaptive_card( + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "type": "AdaptiveCard", + "body": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": ( + [ + { + "type": "Image", + "altText": "", + "url": profile.get("imageUri", ""), + "style": "Person", + "size": "Small", + } + ] + if profile.get("imageUri") + else [] + ), + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "weight": "Bolder", + "text": profile["displayName"], + }, + { + "type": "Container", + "spacing": "Small", + "items": [ + { + "type": "TextBlock", + "text": profile["jobTitle"], + "spacing": "Small", + }, + { + "type": "TextBlock", + "text": profile["mail"], + "spacing": "None", + }, + { + "type": "TextBlock", + "text": profile["givenName"], + "spacing": "None", + }, + { + "type": "TextBlock", + "text": profile["surname"], + "spacing": "None", + }, + ], + }, + ], + }, + ], + } + ], + } + ) + + +def create_pr_card(pr): + return CardFactory.adaptive_card( + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": pr.title, + "weight": "Bolder", + "size": "Medium", + }, + {"type": "TextBlock", "text": pr.id}, + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "View Pull Request", + "url": pr.url, + } + ], + } + ) \ No newline at end of file diff --git a/test_samples/fastapi/shared/github_api_client.py b/test_samples/fastapi/shared/github_api_client.py index 6ecc4cf7..00181900 100644 --- a/test_samples/fastapi/shared/github_api_client.py +++ b/test_samples/fastapi/shared/github_api_client.py @@ -1,116 +1,59 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - import aiohttp from typing import List, Dict, Any - class PullRequest: - """ - Represents a GitHub pull request. - """ - - def __init__(self, id: int, title: str, url: str): + """Represents a GitHub pull request.""" + def __init__(self, id: str, title: str, url: str): self.id = id self.title = title self.url = url - def to_dict(self) -> Dict[str, Any]: - return {"id": self.id, "title": self.title, "url": self.url} - - -class GitHubClient: - """ - A simple GitHub API client using aiohttp. - """ - - @staticmethod - async def get_current_profile(token: str) -> Dict[str, Any]: - """ - Get information about the current authenticated user. - """ - async with aiohttp.ClientSession() as session: - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json", - "User-Agent": "AgentsSDKDemo", - "Content-Type": "application/json", - } - async with session.get( - "https://api.github.com/user", headers=headers - ) as response: - if response.status == 200: - data = await response.json() - return { - "displayName": data.get("name", ""), - "mail": data.get("html_url", ""), - "jobTitle": "", - "givenName": data.get("login", ""), - "surname": "", - "imageUri": data.get("avatar_url", ""), - } - else: - error_text = await response.text() - raise Exception( - f"Error fetching user profile: {response.status} - {error_text}" - ) - - @staticmethod - async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullRequest]: - """ - Get pull requests for a specific repository. - """ - async with aiohttp.ClientSession() as session: - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json", - "User-Agent": "AgentsSDKDemo", - "Content-Type": "application/json", - } - url = f"https://api.github.com/repos/{owner}/{repo}/pulls" - async with session.get(url, headers=headers) as response: - if response.status == 200: - data = await response.json() - return [ - PullRequest( - id=pr.get("id"), - title=pr.get("title"), - url=pr.get("html_url"), - ) - for pr in data - ] - else: - error_text = await response.text() - raise Exception( - f"Error fetching pull requests: {response.status} - {error_text}" - ) - - @staticmethod - async def get_user_pull_requests(token: str) -> List[PullRequest]: - """ - Get pull requests created by the authenticated user across all repositories. - """ - async with aiohttp.ClientSession() as session: - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json", - "User-Agent": "AgentsSDKDemo", - "Content-Type": "application/json", - } - url = "https://api.github.com/search/issues?q=type:pr+author:@me" - async with session.get(url, headers=headers) as response: - if response.status == 200: - data = await response.json() - return [ - PullRequest( - id=pr.get("id"), - title=pr.get("title"), - url=pr.get("html_url"), - ) - for pr in data.get("items", []) - ] - else: - error_text = await response.text() - raise Exception( - f"Error fetching user pull requests: {response.status} - {error_text}" +async def get_current_profile(token: str) -> Dict[str, Any]: + """Get information about the current authenticated user.""" + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgentsSDKDemo", + "Content-Type": "application/json", + } + async with session.get( + "https://api.github.com/user", headers=headers + ) as response: + if response.status == 200: + data = await response.json() + return { + "displayName": data.get("name", ""), + "mail": data.get("html_url", ""), + "jobTitle": "", + "givenName": data.get("login", ""), + "surname": "", + "imageUri": data.get("avatar_url", ""), + } + error_text = await response.text() + raise Exception(f"Error fetching user profile: {response.status} - {error_text}") + +async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullRequest]: + """Get pull requests for a specific repository.""" + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "test-agent", + } + url = f"https://api.github.com/repos/{owner}/{repo}/pulls" + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return [ + PullRequest( + id=pr.get("id"), + title=pr.get("title"), + url=pr.get("htmlUrl"), ) + for pr in data[-5:-1] + ] + error_text = await response.text() + raise Exception( + f"Error fetching pull requests: {response.status} - {error_text}" + ) \ No newline at end of file diff --git a/test_samples/fastapi/shared/user_graph_client.py b/test_samples/fastapi/shared/user_graph_client.py index 05bbe57f..496faaba 100644 --- a/test_samples/fastapi/shared/user_graph_client.py +++ b/test_samples/fastapi/shared/user_graph_client.py @@ -1,31 +1,18 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - import aiohttp - -class GraphClient: +async def get_user_info(token): """ - A simple Microsoft Graph client using aiohttp. + Get information about the current user from Microsoft Graph API. """ - - @staticmethod - async def get_me(token: str): - """ - Get information about the current user. - """ - async with aiohttp.ClientSession() as session: - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - async with session.get( - f"https://graph.microsoft.com/v1.0/me", headers=headers - ) as response: - if response.status == 200: - return await response.json() - else: - error_text = await response.text() - raise Exception( - f"Error from Graph API: {response.status} - {error_text}" - ) + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + async with session.get( + "https://graph.microsoft.com/v1.0/me", headers=headers + ) as response: + if response.status == 200: + return await response.json() + error_text = await response.text() + raise Exception(f"Error from Graph API: {response.status} - {error_text}") \ No newline at end of file From 268d52394ee60ddffd5f0840e9e6e55d1b5b7e1b Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 24 Oct 2025 15:50:43 -0700 Subject: [PATCH 09/19] Fastapi integration tentative, need to validate auth behavior: nit fix --- .../microsoft_agents/hosting/fastapi/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py index 62787062..c3064151 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py @@ -15,8 +15,6 @@ "start_agent_process", "AgentHttpAdapter", "CloudAdapter", - "jwt_authorization_dependency", - "jwt_authorization_decorator", "JwtAuthorizationMiddleware", "channel_service_route_table", "Citation", From 1e0a30637198bdc3cb468e3f832976d4bcb68885 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 27 Oct 2025 13:48:51 -0700 Subject: [PATCH 10/19] Reapply "1. TypingIndicator Concurrency & Argument fixes (#187)" This reverts commit e7f0cad07e8e4c1d20d72dda532aedd00b38b7cb. --- .../hosting/core/app/agent_application.py | 13 ++-- .../hosting/core/app/typing_indicator.py | 78 ++++++++++++------- .../hosting_core/app/test_typing_indicator.py | 76 ++++++++++++++++++ 3 files changed, 133 insertions(+), 34 deletions(-) create mode 100644 tests/hosting_core/app/test_typing_indicator.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index b09605f1..ac777846 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -93,7 +93,6 @@ def __init__( :param kwargs: Additional configuration parameters. :type kwargs: Any """ - self.typing = TypingIndicator() self._route_list = _RouteList[StateT]() configuration = kwargs @@ -659,9 +658,12 @@ async def on_turn(self, context: TurnContext): await self._start_long_running_call(context, self._on_turn) async def _on_turn(self, context: TurnContext): + typing = None try: if context.activity.type != ActivityTypes.typing: - await self._start_typing(context) + if self._options.start_typing_timer: + typing = TypingIndicator() + await typing.start(context) self._remove_mentions(context) @@ -709,11 +711,8 @@ async def _on_turn(self, context: TurnContext): ) await self._on_error(context, err) finally: - self.typing.stop() - - async def _start_typing(self, context: TurnContext): - if self._options.start_typing_timer: - await self.typing.start(context) + if typing: + await typing.stop() def _remove_mentions(self, context: TurnContext): if ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index b33c568f..24b3c0a0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -4,9 +4,9 @@ """ from __future__ import annotations +import asyncio import logging -from threading import Timer from typing import Optional from microsoft_agents.hosting.core import TurnContext @@ -20,36 +20,60 @@ class TypingIndicator: Encapsulates the logic for sending "typing" activity to the user. """ - _interval: int - _timer: Optional[Timer] = None - - def __init__(self, interval=1000) -> None: - self._interval = interval + def __init__(self, intervalSeconds=1) -> None: + self._intervalSeconds = intervalSeconds + self._task: Optional[asyncio.Task] = None + self._running: bool = False + self._lock = asyncio.Lock() async def start(self, context: TurnContext) -> None: - if self._timer is not None: - return + async with self._lock: + if self._running: + return + + logger.debug( + f"Starting typing indicator with interval: {self._intervalSeconds} seconds" + ) + self._running = True + self._task = asyncio.create_task(self._typing_loop(context)) - logger.debug(f"Starting typing indicator with interval: {self._interval} ms") - func = self._on_timer(context) - self._timer = Timer(self._interval, func) - self._timer.start() - await func() + async def stop(self) -> None: + async with self._lock: + if not self._running: + return - def stop(self) -> None: - if self._timer: logger.debug("Stopping typing indicator") - self._timer.cancel() - self._timer = None + self._running = False + task = self._task + self._task = None - def _on_timer(self, context: TurnContext): - async def __call__(): + # Cancel outside the lock to avoid blocking + if task and not task.done(): + task.cancel() try: - logger.debug("Sending typing activity") - await context.send_activity(Activity(type=ActivityTypes.typing)) - except Exception as e: - # TODO: Improve when adding logging - logger.error(f"Error sending typing activity: {e}") - self.stop() - - return __call__ + await task + except asyncio.CancelledError: + pass + + async def _typing_loop(self, context: TurnContext): + """Continuously send typing indicators at the specified interval.""" + try: + while True: + # Check running status under lock + async with self._lock: + if not self._running: + break + + try: + logger.debug("Sending typing activity") + await context.send_activity(Activity(type=ActivityTypes.typing)) + except Exception as e: + logger.error(f"Error sending typing activity: {e}") + async with self._lock: + self._running = False + break + + await asyncio.sleep(self._intervalSeconds) + except asyncio.CancelledError: + logger.debug("Typing indicator loop cancelled") + raise diff --git a/tests/hosting_core/app/test_typing_indicator.py b/tests/hosting_core/app/test_typing_indicator.py new file mode 100644 index 00000000..22a09c8f --- /dev/null +++ b/tests/hosting_core/app/test_typing_indicator.py @@ -0,0 +1,76 @@ +import asyncio + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core.app.typing_indicator import TypingIndicator + + +class StubTurnContext: + """Test double that tracks sent activities.""" + + def __init__(self, should_raise: bool = False) -> None: + self.sent_activities = [] + self.should_raise = should_raise + + async def send_activity(self, activity: Activity): + if self.should_raise: + raise RuntimeError("send_activity failure") + self.sent_activities.append(activity) + return None + + +@pytest.mark.asyncio +async def test_start_sends_typing_activity(): + context = StubTurnContext() + indicator = TypingIndicator(intervalSeconds=0.01) + + await indicator.start(context) + await asyncio.sleep(0.03) + await indicator.stop() + + assert len(context.sent_activities) >= 1 + assert all(activity.type == ActivityTypes.typing for activity in context.sent_activities) + + +@pytest.mark.asyncio +async def test_start_is_idempotent(): + context = StubTurnContext() + indicator = TypingIndicator(intervalSeconds=0.01) + + await indicator.start(context) + first_task = indicator._task # noqa: SLF001 - accessing for test verification + + await indicator.start(context) + second_task = indicator._task # noqa: SLF001 + + assert first_task is second_task + + await indicator.stop() + + +@pytest.mark.asyncio +async def test_stop_without_start_is_noop(): + indicator = TypingIndicator() + + await indicator.stop() + + assert indicator._task is None # noqa: SLF001 + assert indicator._running is False # noqa: SLF001 + + +@pytest.mark.asyncio +async def test_typing_loop_stops_on_send_error(): + context = StubTurnContext(should_raise=True) + indicator = TypingIndicator(intervalSeconds=0.01) + + await indicator.start(context) + await asyncio.sleep(0.02) + + assert indicator._task is not None # noqa: SLF001 + await asyncio.wait_for(indicator._task, timeout=0.1) # Ensure loop exits + + assert indicator._running is False # noqa: SLF001 + assert indicator._task.done() # noqa: SLF001 + + await indicator.stop() From c0807d627e29ad88cb102baf097a645d3f02bea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 27 Oct 2025 13:57:25 -0700 Subject: [PATCH 11/19] Update test_samples/fastapi/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test_samples/fastapi/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/fastapi/README.md b/test_samples/fastapi/README.md index c6a5661b..986af23e 100644 --- a/test_samples/fastapi/README.md +++ b/test_samples/fastapi/README.md @@ -5,7 +5,7 @@ This folder contains FastAPI equivalents of the agent samples from the `app_styl ## Samples ### 1. empty_agent.py -A simple echo agent that responds to messages and provides basic help functionality. This is the FastAPI equivalent of `emtpy_agent.py`. +A simple echo agent that responds to messages and provides basic help functionality. This is the FastAPI equivalent of `empty_agent.py`. **Features:** - Basic message echoing From 36bf4603756a17a35759b85a8d3dddd865802463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 27 Oct 2025 14:03:16 -0700 Subject: [PATCH 12/19] Update test_samples/fastapi/shared/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test_samples/fastapi/shared/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/fastapi/shared/__init__.py b/test_samples/fastapi/shared/__init__.py index 5971e2f2..bed762f1 100644 --- a/test_samples/fastapi/shared/__init__.py +++ b/test_samples/fastapi/shared/__init__.py @@ -5,4 +5,4 @@ from .user_graph_client import get_user_info from .cards import create_profile_card, create_pr_card -__all__ = ["PullRequest", "get_current_profile", "get_pull_requests", "get_user_info", "create_profile_card", "create_pr_card", "get_user_info"] +__all__ = ["PullRequest", "get_current_profile", "get_pull_requests", "get_user_info", "create_profile_card", "create_pr_card"] From 8174a7851be71278fc25dc84f3fcec704f53174b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 27 Oct 2025 14:05:32 -0700 Subject: [PATCH 13/19] Update test_samples/fastapi/shared/github_api_client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test_samples/fastapi/shared/github_api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/fastapi/shared/github_api_client.py b/test_samples/fastapi/shared/github_api_client.py index 00181900..1847bd12 100644 --- a/test_samples/fastapi/shared/github_api_client.py +++ b/test_samples/fastapi/shared/github_api_client.py @@ -51,7 +51,7 @@ async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullReque title=pr.get("title"), url=pr.get("htmlUrl"), ) - for pr in data[-5:-1] + for pr in data[-5:] ] error_text = await response.text() raise Exception( From e4784cf44f64fff950332ef186f455bb0c494f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 27 Oct 2025 14:06:57 -0700 Subject: [PATCH 14/19] Update test_samples/fastapi/authorization_agent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test_samples/fastapi/authorization_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py index b13a7529..02096f4f 100644 --- a/test_samples/fastapi/authorization_agent.py +++ b/test_samples/fastapi/authorization_agent.py @@ -75,7 +75,7 @@ async def logout(context: TurnContext, state: TurnState) -> None: ) async def profile_request(context: TurnContext, state: TurnState) -> None: user_token_response = await AGENT_APP.auth.get_token(context, "GRAPH") - if user_token_response and user_token_response is not None: + if user_token_response: user_info = await get_user_info(user_token_response.token) activity = MessageFactory.attachment(create_profile_card(user_info)) await context.send_activity(activity) From 19cf97ccdc12bd4ab941b587dfbf1011018e6330 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 27 Oct 2025 14:12:20 -0700 Subject: [PATCH 15/19] Fastapi integration tentative, need to validate auth behavior: copilot rreview fixers --- test_samples/fastapi/authorization_agent.py | 11 ++++++++--- test_samples/fastapi/empty_agent.py | 2 +- test_samples/fastapi/shared/__init__.py | 10 +++++++++- test_samples/fastapi/shared/cards.py | 3 ++- test_samples/fastapi/shared/github_api_client.py | 10 ++++++++-- test_samples/fastapi/shared/user_graph_client.py | 3 ++- 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py index b13a7529..06d6d87e 100644 --- a/test_samples/fastapi/authorization_agent.py +++ b/test_samples/fastapi/authorization_agent.py @@ -2,10 +2,9 @@ # Licensed under the MIT License. import logging +import json from os import environ, path import re -import sys -import traceback import uvicorn from dotenv import load_dotenv @@ -26,7 +25,13 @@ ) from microsoft_agents.authentication.msal import MsalConnectionManager -from shared import get_current_profile, get_pull_requests, get_user_info, create_profile_card, create_pr_card +from shared import ( + get_current_profile, + get_pull_requests, + get_user_info, + create_profile_card, + create_pr_card, +) logger = logging.getLogger(__name__) diff --git a/test_samples/fastapi/empty_agent.py b/test_samples/fastapi/empty_agent.py index 2d079e6c..0d984379 100644 --- a/test_samples/fastapi/empty_agent.py +++ b/test_samples/fastapi/empty_agent.py @@ -26,7 +26,7 @@ # Agent handlers async def _help(context: TurnContext, _state: TurnState): await context.send_activity( - "Welcome to the Echo Agent sample 🚀. " + "Welcome to the Empty Agent Sample 🚀. " "Type /help for help or send a message to see the echo feature in action." ) diff --git a/test_samples/fastapi/shared/__init__.py b/test_samples/fastapi/shared/__init__.py index 5971e2f2..ebeb1b33 100644 --- a/test_samples/fastapi/shared/__init__.py +++ b/test_samples/fastapi/shared/__init__.py @@ -5,4 +5,12 @@ from .user_graph_client import get_user_info from .cards import create_profile_card, create_pr_card -__all__ = ["PullRequest", "get_current_profile", "get_pull_requests", "get_user_info", "create_profile_card", "create_pr_card", "get_user_info"] +__all__ = [ + "PullRequest", + "get_current_profile", + "get_pull_requests", + "get_user_info", + "create_profile_card", + "create_pr_card", + "get_user_info", +] diff --git a/test_samples/fastapi/shared/cards.py b/test_samples/fastapi/shared/cards.py index 8788e22a..0fd438ec 100644 --- a/test_samples/fastapi/shared/cards.py +++ b/test_samples/fastapi/shared/cards.py @@ -1,5 +1,6 @@ from microsoft_agents.hosting.core import CardFactory + def create_profile_card(profile): return CardFactory.adaptive_card( { @@ -94,4 +95,4 @@ def create_pr_card(pr): } ], } - ) \ No newline at end of file + ) diff --git a/test_samples/fastapi/shared/github_api_client.py b/test_samples/fastapi/shared/github_api_client.py index 00181900..18255dce 100644 --- a/test_samples/fastapi/shared/github_api_client.py +++ b/test_samples/fastapi/shared/github_api_client.py @@ -1,13 +1,16 @@ import aiohttp from typing import List, Dict, Any + class PullRequest: """Represents a GitHub pull request.""" + def __init__(self, id: str, title: str, url: str): self.id = id self.title = title self.url = url + async def get_current_profile(token: str) -> Dict[str, Any]: """Get information about the current authenticated user.""" async with aiohttp.ClientSession() as session: @@ -31,7 +34,10 @@ async def get_current_profile(token: str) -> Dict[str, Any]: "imageUri": data.get("avatar_url", ""), } error_text = await response.text() - raise Exception(f"Error fetching user profile: {response.status} - {error_text}") + raise Exception( + f"Error fetching user profile: {response.status} - {error_text}" + ) + async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullRequest]: """Get pull requests for a specific repository.""" @@ -56,4 +62,4 @@ async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullReque error_text = await response.text() raise Exception( f"Error fetching pull requests: {response.status} - {error_text}" - ) \ No newline at end of file + ) diff --git a/test_samples/fastapi/shared/user_graph_client.py b/test_samples/fastapi/shared/user_graph_client.py index 496faaba..0af2fa4e 100644 --- a/test_samples/fastapi/shared/user_graph_client.py +++ b/test_samples/fastapi/shared/user_graph_client.py @@ -1,5 +1,6 @@ import aiohttp + async def get_user_info(token): """ Get information about the current user from Microsoft Graph API. @@ -15,4 +16,4 @@ async def get_user_info(token): if response.status == 200: return await response.json() error_text = await response.text() - raise Exception(f"Error from Graph API: {response.status} - {error_text}") \ No newline at end of file + raise Exception(f"Error from Graph API: {response.status} - {error_text}") From 5e948bcc47739f06d32ed2984c52caad586999f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 27 Oct 2025 14:25:02 -0700 Subject: [PATCH 16/19] Update test_samples/fastapi/shared/github_api_client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test_samples/fastapi/shared/github_api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/fastapi/shared/github_api_client.py b/test_samples/fastapi/shared/github_api_client.py index 40a601c3..2f0505f9 100644 --- a/test_samples/fastapi/shared/github_api_client.py +++ b/test_samples/fastapi/shared/github_api_client.py @@ -55,7 +55,7 @@ async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullReque PullRequest( id=pr.get("id"), title=pr.get("title"), - url=pr.get("htmlUrl"), + url=pr.get("html_url"), ) for pr in data[-5:] ] From 6b9f0f5aff700744cde479f0fec4c500f391209d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 27 Oct 2025 14:26:14 -0700 Subject: [PATCH 17/19] Update libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../hosting/fastapi/app/streaming/streaming_response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py index 73c5e895..b68d6d0d 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/app/streaming/streaming_response.py @@ -355,7 +355,7 @@ async def _send_activity(self, activity: Activity) -> None: activity.id = self._stream_id streaminfo_entity.stream_id = self._stream_id - if self._citations and len(self._citations) > 0 and not self._ended: + if self._citations and not self._ended: # Filter out the citations unused in content. curr_citations = CitationUtil.get_used_citations( self._message, self._citations From 7c3d1af23330a7e6c51de42125f3e0f6f4402f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 27 Oct 2025 14:26:37 -0700 Subject: [PATCH 18/19] Update test_samples/fastapi/shared/cards.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test_samples/fastapi/shared/cards.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/fastapi/shared/cards.py b/test_samples/fastapi/shared/cards.py index 0fd438ec..c8aa251e 100644 --- a/test_samples/fastapi/shared/cards.py +++ b/test_samples/fastapi/shared/cards.py @@ -85,7 +85,7 @@ def create_pr_card(pr): "weight": "Bolder", "size": "Medium", }, - {"type": "TextBlock", "text": pr.id}, + {"type": "TextBlock", "text": str(pr.id)}, ], "actions": [ { From a72346cebf5d1af6111828c56726dcf456e97924 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 27 Oct 2025 14:27:06 -0700 Subject: [PATCH 19/19] Fastapi integration tentative, need to validate auth behavior: nit fix --- test_samples/fastapi/shared/__init__.py | 3 +-- test_samples/fastapi/shared/github_api_client.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test_samples/fastapi/shared/__init__.py b/test_samples/fastapi/shared/__init__.py index ebeb1b33..be7909b4 100644 --- a/test_samples/fastapi/shared/__init__.py +++ b/test_samples/fastapi/shared/__init__.py @@ -11,6 +11,5 @@ "get_pull_requests", "get_user_info", "create_profile_card", - "create_pr_card", - "get_user_info", + "create_pr_card" ] diff --git a/test_samples/fastapi/shared/github_api_client.py b/test_samples/fastapi/shared/github_api_client.py index 40a601c3..18255dce 100644 --- a/test_samples/fastapi/shared/github_api_client.py +++ b/test_samples/fastapi/shared/github_api_client.py @@ -57,7 +57,7 @@ async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullReque title=pr.get("title"), url=pr.get("htmlUrl"), ) - for pr in data[-5:] + for pr in data[-5:-1] ] error_text = await response.text() raise Exception(