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/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/hosting/fastapi/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py new file mode 100644 index 00000000..c3064151 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py @@ -0,0 +1,23 @@ +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 ( + JwtAuthorizationMiddleware, +) +from .app.streaming import ( + Citation, + CitationUtil, + StreamingResponse, +) + +__all__ = [ + "start_agent_process", + "AgentHttpAdapter", + "CloudAdapter", + "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..b68d6d0d --- /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: {type(err).__name__}" + ) + 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 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..3383c793 --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from traceback import format_exc +from typing import Optional + +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..44abee1a --- /dev/null +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -0,0 +1,77 @@ +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, +) + + +logger = logging.getLogger(__name__) + + +class JwtAuthorizationMiddleware: + """Starlette-compatible ASGI middleware for JWT authorization. + + Usage: + from fastapi import FastAPI + + app = FastAPI() + app.add_middleware(JwtAuthorizationMiddleware) + """ + + 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 + + app = scope.get("app") + state = getattr(app, "state", None) if app else None + auth_config: AgentAuthConfiguration = getattr( + state, "agent_configuration", None + ) + + request = Request(scope, receive=receive) + token_validator = JwtTokenValidator(auth_config) + auth_header = request.headers.get("Authorization") + + if auth_header: + parts = auth_header.split(" ") + if len(parts) == 2 and parts[0].lower() == "bearer": + token = parts[1] + try: + claims = await 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, + ) + await response(scope, receive, send) + return + else: + if not auth_config or not auth_config.CLIENT_ID: + request.state.claims_identity = ( + await token_validator.get_anonymous_claims() + ) + else: + response = JSONResponse( + {"error": "Authorization header not found"}, + status_code=401, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) 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", + ], +) diff --git a/test_samples/fastapi/README.md b/test_samples/fastapi/README.md new file mode 100644 index 00000000..986af23e --- /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 `empty_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..b2265893 --- /dev/null +++ b/test_samples/fastapi/authorization_agent.py @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +import json +from os import environ, path +import re + +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, + JwtAuthorizationMiddleware, +) +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, +) + +logger = logging.getLogger(__name__) + +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 +) + + +@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. + """ + 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'}" + ) + ) + + +@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"] +) +async def profile_request(context: TurnContext, state: TurnState) -> None: + user_token_response = await AGENT_APP.auth.get_token(context, "GRAPH") + 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) + else: + await context.send_activity( + MessageFactory.text('Token not available. Enter "login" to sign in.') + ) + + +@AGENT_APP.message( + re.compile(r"^/(prs|pull requests)$", re.IGNORECASE), auth_handlers=["GITHUB"] +) +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.attachment(create_profile_card(gh_prof)) + ) + + # 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 + ) + 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" + ) + 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"GitHub token length: {len(token_response.token)}") + ) + else: + await context.send_activity( + MessageFactory.text("Failed to obtain GitHub token.") + ) + + +@AGENT_APP.activity(ActivityTypes.invoke) +async def invoke(context: TurnContext, state: TurnState) -> None: + await context.send_activity(MessageFactory.text("Invoke activity received.")) + + +@AGENT_APP.activity(ActivityTypes.message) +async def message(context: TurnContext, state: TurnState) -> None: + await context.send_activity( + MessageFactory.text(f"You said: {context.activity.text}") + ) + + +# 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, +): + """Main endpoint for processing bot messages.""" + + 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..0d984379 --- /dev/null +++ b/test_samples/fastapi/empty_agent.py @@ -0,0 +1,67 @@ +# 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, + JwtAuthorizationMiddleware, +) + +# 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") +app.add_middleware(JwtAuthorizationMiddleware) + + +# Agent handlers +async def _help(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the Empty 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, +): + """Main endpoint for processing bot messages.""" + + 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..be7909b4 --- /dev/null +++ b/test_samples/fastapi/shared/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +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__ = [ + "PullRequest", + "get_current_profile", + "get_pull_requests", + "get_user_info", + "create_profile_card", + "create_pr_card" +] diff --git a/test_samples/fastapi/shared/cards.py b/test_samples/fastapi/shared/cards.py new file mode 100644 index 00000000..c8aa251e --- /dev/null +++ b/test_samples/fastapi/shared/cards.py @@ -0,0 +1,98 @@ +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": str(pr.id)}, + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "View Pull Request", + "url": pr.url, + } + ], + } + ) 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..642133a8 --- /dev/null +++ b/test_samples/fastapi/shared/github_api_client.py @@ -0,0 +1,65 @@ +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: + 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("html_url"), + ) + for pr in data[-5:-1] + ] + error_text = await response.text() + raise Exception( + f"Error fetching 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..0af2fa4e --- /dev/null +++ b/test_samples/fastapi/shared/user_graph_client.py @@ -0,0 +1,19 @@ +import aiohttp + + +async def get_user_info(token): + """ + Get information about the current user from Microsoft Graph API. + """ + 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}")