chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
## Semantic Kernel Demo Applications
Demonstration applications that leverage the usage of one or many SK features
| Type | Description |
| ----------------- | ----------------------------------------------- |
| assistants_group_chat | A sample Agent demo that shows a chat functionality with an OpenAI Assistant agent. |
| booking_restaurant | A sample chat bot that leverages the Microsoft Graph and Bookings API as a Semantic Kernel plugin to make a fake booking at a restaurant. |
| copilot_studio_agent | A sample that shows how to invoke Microsoft Copilot Studio agents as first-party Agent in Semantic Kernel|
| copilot_studio_skill | A sample demonstrating how to extend Microsoft Copilot Studio to invoke Semantic Kernel agents |
| guided_conversations | A sample showing a framework for a pattern of use cases referred to as guided conversations. |
| processes_with_dapr | A sample showing the Semantic Kernel process framework used with the Python Dapr runtime. |
| telemetry_with_application_insights | A sample project that shows how a Python application can be configured to send Semantic Kernel telemetry to Application Insights. |
@@ -0,0 +1,159 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
import re
from semantic_kernel.agents import AgentGroupChat, OpenAIAssistantAgent
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
"""
The following sample demonstrates how to create a Semantic Kernel
OpenAIAssistantAgent, and leverage the assistant's
code interpreter or file search capabilities. The user interacts
with the AI assistant by uploading files and chatting.
Note: This sample use the `AgentGroupChat` feature of Semantic Kernel, which is
no longer maintained. For a replacement, consider using the `GroupChatOrchestration`.
Read more about the `GroupChatOrchestration` here:
https://learn.microsoft.com/semantic-kernel/frameworks/agent/agent-orchestration/group-chat?pivots=programming-language-python
Here is a migration guide from `AgentGroupChat` to `GroupChatOrchestration`:
https://learn.microsoft.com/semantic-kernel/support/migration/group-chat-orchestration-migration-guide?pivots=programming-language-python
"""
# region Helper Functions
def display_intro_message():
print(
"""
Chat with an AI assistant backed by a Semantic Kernel OpenAIAssistantAgent.
To start: you can upload files to the assistant using the command (brackets included):
[upload code_interpreter | file_search file_path]
where `code_interpreter` or `file_search` is the purpose of the file and
`file_path` is the path to the file. For example:
[upload code_interpreter file.txt]
This will upload file.txt to the assistant for use with the code interpreter tool.
Type "exit" to exit the chat.
"""
)
def parse_upload_command(user_input: str):
"""Parse the user input for an upload command."""
match = re.search(r"\[upload\s+(code_interpreter|file_search)\s+(.+)\]", user_input)
if match:
return match.group(1), match.group(2)
return None, None
async def handle_file_upload(assistant_agent: OpenAIAssistantAgent, purpose: str, file_path: str):
"""Handle the file upload command."""
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
file_id = await assistant_agent.add_file(file_path, purpose="assistants")
print(f"File uploaded: {file_id}")
if purpose == "code_interpreter":
await enable_code_interpreter(assistant_agent, file_id)
elif purpose == "file_search":
await enable_file_search(assistant_agent, file_id)
async def enable_code_interpreter(assistant_agent: OpenAIAssistantAgent, file_id: str):
"""Enable the file for code interpreter."""
assistant_agent.code_interpreter_file_ids.append(file_id)
tools = [{"type": "file_search"}, {"type": "code_interpreter"}]
tool_resources = {"code_interpreter": {"file_ids": assistant_agent.code_interpreter_file_ids}}
await assistant_agent.modify_assistant(
assistant_id=assistant_agent.assistant.id, tools=tools, tool_resources=tool_resources
)
print("File enabled for code interpreter.")
async def enable_file_search(assistant_agent: OpenAIAssistantAgent, file_id: str):
"""Enable the file for file search."""
if assistant_agent.vector_store_id is not None:
await assistant_agent.client.beta.vector_stores.files.create(
vector_store_id=assistant_agent.vector_store_id, file_id=file_id
)
assistant_agent.file_search_file_ids.append(file_id)
else:
vector_store = await assistant_agent.create_vector_store(file_ids=file_id)
assistant_agent.file_search_file_ids.append(file_id)
assistant_agent.vector_store_id = vector_store.id
tools = [{"type": "file_search"}, {"type": "code_interpreter"}]
tool_resources = {"file_search": {"vector_store_ids": [vector_store.id]}}
await assistant_agent.modify_assistant(
assistant_id=assistant_agent.assistant.id, tools=tools, tool_resources=tool_resources
)
print("File enabled for file search.")
async def cleanup_resources(assistant_agent: OpenAIAssistantAgent):
"""Cleanup the resources used by the assistant."""
if assistant_agent.vector_store_id:
await assistant_agent.delete_vector_store(assistant_agent.vector_store_id)
for file_id in assistant_agent.code_interpreter_file_ids:
await assistant_agent.delete_file(file_id)
for file_id in assistant_agent.file_search_file_ids:
await assistant_agent.delete_file(file_id)
await assistant_agent.delete()
# endregion
async def main():
assistant_agent = None
try:
display_intro_message()
# Create the OpenAI Assistant Agent
assistant_agent = await OpenAIAssistantAgent.create(
service_id="AIAssistant",
description="An AI assistant that helps with everyday tasks.",
instructions="Help the user with their task.",
enable_code_interpreter=True,
enable_file_search=True,
)
# Define an agent group chat, which drives the conversation
# We add messages to the chat and then invoke the agent to respond.
chat = AgentGroupChat()
while True:
try:
user_input = input("User:> ")
except (KeyboardInterrupt, EOFError):
print("\n\nExiting chat...")
break
if user_input.strip().lower() == "exit":
print("\n\nExiting chat...")
break
purpose, file_path = parse_upload_command(user_input)
if purpose and file_path:
await handle_file_upload(assistant_agent, purpose, file_path)
continue
await chat.add_chat_message(message=ChatMessageContent(role=AuthorRole.USER, content=user_input))
async for content in chat.invoke(agent=assistant_agent):
print(f"Assistant:> # {content.role} - {content.name or '*'}: '{content.content}'")
finally:
if assistant_agent:
await cleanup_resources(assistant_agent)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,139 @@
# Restaurant - Demo Application
This sample provides a practical demonstration of how to leverage features from the [Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel) to build a console application. Specifically, the application utilizes the [Business Schedule and Booking API](https://www.microsoft.com/en-us/microsoft-365/business/scheduling-and-booking-app) through Microsoft Graph to enable a Large Language Model (LLM) to book restaurant appointments efficiently. This guide will walk you through the necessary steps to integrate these technologies seamlessly.
## Prerequisites
- Python 3.10, 3.11, or 3.12.
- [Microsoft 365 Business License](https://www.microsoft.com/en-us/microsoft-365/business/compare-all-microsoft-365-business-products) to use [Business Schedule and Booking API](https://www.microsoft.com/en-us/microsoft-365/business/scheduling-and-booking-app).
- [Azure Entra Id](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id) administrator account to register an application and set the necessary credentials and permissions.
### Function Calling Enabled Models
This sample uses function calling capable models and has been tested with the following models:
| Model type | Model name/id | Model version | Supported |
| --------------- | ------------------------- | ------------------: | --------- |
| Chat Completion | gpt-3.5-turbo | 0125 | ✅ |
| Chat Completion | gpt-3.5-turbo | 1106 | ✅ |
| Chat Completion | gpt-3.5-turbo-0613 | 0613 | ✅ |
| Chat Completion | gpt-3.5-turbo-0301 | 0301 | ❌ |
| Chat Completion | gpt-3.5-turbo-16k | 0613 | ✅ |
| Chat Completion | gpt-4 | 0613 | ✅ |
| Chat Completion | gpt-4-0613 | 0613 | ✅ |
| Chat Completion | gpt-4-0314 | 0314 | ❌ |
| Chat Completion | gpt-4-turbo | 2024-04-09 | ✅ |
| Chat Completion | gpt-4-turbo-2024-04-09 | 2024-04-09 | ✅ |
| Chat Completion | gpt-4-turbo-preview | 0125-preview | ✅ |
| Chat Completion | gpt-4-0125-preview | 0125-preview | ✅ |
| Chat Completion | gpt-4-vision-preview | 1106-vision-preview | ✅ |
| Chat Completion | gpt-4-1106-vision-preview | 1106-vision-preview | ✅ |
️ OpenAI Models older than 0613 version do not support function calling.
️ When using Azure OpenAI, ensure that the model name of your deployment matches any of the above supported models names.
## Configuring the sample
Please make sure either your environment variables or your .env file contains the following:
- "BOOKING_SAMPLE_CLIENT_ID"
- "BOOKING_SAMPLE_TENANT_ID"
- "BOOKING_SAMPLE_CLIENT_SECRET"
- "BOOKING_SAMPLE_BUSINESS_ID"
- "BOOKING_SAMPLE_SERVICE_ID"
If wanting to use the `.env` file, you must pass the `env_file_path` parameter with a valid path:
```python
booking_sample_settings = BookingSampleSettings(env_file_path=env_file_path)
```
This will tell Pydantic settings to also load the `.env` file instead of just trying to load environment variables.
### Create an App Registration in Azure Active Directory
1. Go to the [Azure Portal](https://portal.azure.com/).
2. Select the Azure Active Directory service.
3. Select App registrations and click on New registration.
4. Fill in the required fields and click on Register.
5. Copy the Application **(client) Id** for later use.
6. Save Directory **(tenant) Id** for later use..
7. Click on Certificates & secrets and create a new client secret. (Any name and expiration date will work)
8. Copy the **client secret** value for later use.
9. Click on API permissions and add the following permissions:
- Microsoft Graph
- Application permissions
- BookingsAppointment.ReadWrite.All
- Delegated permissions
- OpenId permissions
- offline_access
- profile
- openid
### Create Or Use a Booking Service and Business
1. Go to the [Bookings Homepage](https://outlook.office.com/bookings) website.
2. Create a new Booking Page and add a Service to the Booking (Skip if you don't ).
3. Access [Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer)
4. Run the following query to get the Booking Business Id:
```http
GET https://graph.microsoft.com/v1.0/solutions/bookingBusinesses
```
5. Copy the **Booking Business Id** for later use.
6. Run the following query and replace it with your **Booking Business Id** to get the Booking Service Id
```http
GET https://graph.microsoft.com/v1.0/solutions/bookingBusinesses/{bookingBusiness-id}/services
```
7. Copy the **Booking Service Id** for later use.
## Running the sample
Either run the sample in an IDE, like VSCode, or via the command line.
### Example of a conversation
> **User** > Please book a restaurant for me.
> **Assistant** > Sure, I can help you with that. Could you please provide me with the following information:
>
> 1. Name of the restaurant you would like to book.
> 2. Date and time of the reservation.
> 3. Number of people in your party.
> 4. Your name.
> 5. Your email address.
> 6. Your phone number.
>
> Once I have all this information, I will proceed with the booking.
> **User** > I would like to book a table at the restaurant "The French Laundry" on the 15th of March at 7:00 PM for 4 people. My name is John Doe, my email address is john@internet.com, and my phone number is 123-456-7890.
> **System** > Do you want to book a table at The French Laundry on 15/03/2023 19:00:00 for 4 people?
> **System** > Please confirm by typing 'yes' or 'no'.
> **User** > yes
> **Assistant** > Great! I have successfully booked a table for you at "The French Laundry" on the 15th of March at 7:00 PM for 4 people.
>
> Your reservation has been made under the name John Doe.
>
> If you have any further questions or need to make any changes to your reservation, please let me know.
> **User** > List my current bookings
> **Assistant** > You currently have one booking:
>
> - Restaurant: The French Laundry
> - Date and Time: 15th of March at 7:00 PM
> - Party Size: 4 people
>
> If you need any further assistance or if there's anything else I can help you with, please let me know.
> **User** > Cancel my booking
> **System** > `[Cancelling a reservation for 4 at The French Laundry on 2023-03-15 at 19:00:00]`
> **Assistant** > I have successfully canceled your booking at "The French Laundry" on the 15th of March at 7:00 PM for 4 people.
>
> If you have any other questions or need further assistance, please let me know.
@@ -0,0 +1,45 @@
# Copyright (c) Microsoft. All rights reserved.
from pydantic import SecretStr
from pydantic_settings import BaseSettings
class BookingSampleSettings(BaseSettings):
"""Restaurant Booking Sample settings
The settings are first loaded from environment variables with the prefix 'BOOKING_'. If the
environment variables are not found, the settings can be loaded from a .env file with the
encoding 'utf-8'. If the settings are not found in the .env file, the settings are ignored;
however, validation will fail alerting that the settings are missing.
Required settings for prefix 'BOOKING_' are:
- client_id = The App Registration Client ID (Env var BOOKING_CLIENT_ID)
- tenant_id = The App Registration Tenant ID (Env var BOOKING_TENANT_ID)
- client_secret = The App Registration Client Secret (Env var BOOKING_CLIENT_SECRET)
- business_id = The sample booking service ID (Env var BOOKING_BUSINESS_ID)
- service_id = The sample booking service ID (Env var BOOKING_SERVICE_ID)
For more information on these required settings, please see the sample's README.md file.
"""
env_file_path: str | None = None
client_id: str
tenant_id: str
client_secret: SecretStr
business_id: str
service_id: str
class Config:
env_prefix = "BOOKING_"
env_file = None
env_file_encoding = "utf-8"
extra = "ignore"
case_sensitive = False
@classmethod
def create(cls, **kwargs):
if kwargs.get("env_file_path"):
cls.Config.env_file = kwargs["env_file_path"]
else:
cls.Config.env_file = None
return cls(**kwargs)
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
# intentionally left empty
@@ -0,0 +1,140 @@
# Copyright (c) Microsoft. All rights reserved.
from datetime import datetime, timedelta
from typing import Annotated
from msgraph import GraphServiceClient
from msgraph.generated.models.booking_appointment import BookingAppointment
from msgraph.generated.models.booking_customer_information import BookingCustomerInformation
from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone
from msgraph.generated.models.location import Location
from semantic_kernel.functions.kernel_function_decorator import kernel_function
class BookingsPlugin:
"""A plugin for booking tables at a restaurant."""
def __init__(
self,
graph_client: GraphServiceClient,
booking_business_id: str,
booking_service_id: str,
customer_timezone: str = "America/Chicago",
):
"""Initializes a new instance of the BookingsPlugin class.
Args:
graph_client (GraphServiceClient): The GraphServiceClient instance.
booking_business_id (str): The ID of the booking business.
service_id (str): The ID of the service.
customer_timezone (str, optional): The timezone of the customer. Defaults to "America/Chicago".
"""
self.graph_client = graph_client
self.booking_business_id = booking_business_id
self.booking_service_id = booking_service_id
self.customer_timezone = customer_timezone
@kernel_function(name="book_table", description="Book a table at a restaurant")
async def book_table(
self,
restaurant: Annotated[str, "The name of the restaurant"],
date_time: Annotated[str, "The time in UTC, formatted as an ISO datetime string, like 2024-09-15T19:00:00"],
party_size: Annotated[int, "The number of people in the party"],
customer_name: Annotated[str, "The name of the customer"],
customer_email: Annotated[str, "The email of the customer"],
customer_phone: Annotated[str, "The phone number of the customer"],
) -> Annotated[str, "The booking appointment ID"]:
"""Book a table at a restaurant.
Args:
restaurant (str): The name of the restaurant.
date_time (datetime): The time in UTC.
party_size (int): The number of people in the party.
customer_name (str): The name of the customer.
customer_email (str): The email of the customer.
customer_phone (str): The phone number of the customer.
Returns:
str: The status of the booking.
"""
print(f"System > Do you want to book a table at {restaurant} on {date_time} for {party_size} people?")
print("System > Please confirm by typing 'yes' or 'no'.")
confirmation = input("User:> ")
if confirmation.lower() != "yes":
return "Booking aborted by the user."
request_body = BookingAppointment(
odata_type="#microsoft.graph.bookingAppointment",
customer_time_zone=self.customer_timezone,
sms_notifications_enabled=False,
start_date_time=DateTimeTimeZone(
odata_type="#microsoft.graph.dateTimeTimeZone",
date_time=date_time,
time_zone="UTC",
),
end_date_time=DateTimeTimeZone(
odata_type="#microsoft.graph.dateTimeTimeZone",
date_time=(datetime.fromisoformat(date_time) + timedelta(hours=2)).isoformat(),
time_zone="UTC",
),
is_location_online=False,
opt_out_of_customer_email=False,
anonymous_join_web_url=None,
service_id=self.booking_service_id,
service_location=Location(
odata_type="#microsoft.graph.location",
display_name=restaurant,
),
maximum_attendees_count=party_size,
filled_attendees_count=party_size,
customers=[
BookingCustomerInformation(
odata_type="#microsoft.graph.bookingCustomerInformation",
name=customer_name,
email_address=customer_email,
phone=customer_phone,
time_zone=self.customer_timezone,
),
],
additional_data={
"price_type@odata_type": "#microsoft.graph.bookingPriceType",
"reminders@odata_type": "#Collection(microsoft.graph.bookingReminder)",
"customers@odata_type": "#Collection(microsoft.graph.bookingCustomerInformation)",
},
)
response = await self.graph_client.solutions.booking_businesses.by_booking_business_id(
self.booking_business_id
).appointments.post(request_body)
return f"Booking successful! Your reservation ID is {response.id}."
@kernel_function(name="list_revervations", description="List all reservations")
async def list_reservations(self) -> Annotated[str, "The list of reservations"]:
"""List the reservations for the booking business."""
appointments = await self.graph_client.solutions.booking_businesses.by_booking_business_id(
self.booking_business_id
).appointments.get()
return "\n".join([
f"{appointment.service_location.display_name} on {appointment.start_date_time.date_time} with id: {appointment.id}" # noqa: E501
for appointment in appointments.value
])
@kernel_function(name="cancel_reservation", description="Cancel a reservation")
async def cancel_reservation(
self,
reservation_id: Annotated[str, "The ID of the reservation"],
restaurant: Annotated[str, "The name of the restaurant"],
date: Annotated[str, "The date of the reservation"],
time: Annotated[str, "The time of the reservation"],
party_size: Annotated[int, "The number of people in the party"],
) -> Annotated[str, "The cancellation status of the reservation"]:
"""Cancel a reservation."""
print(f"System > [Cancelling a reservation for {party_size} at {restaurant} on {date} at {time}]")
_ = (
await self.graph_client.solutions.booking_businesses.by_booking_business_id(self.booking_business_id)
.appointments.by_booking_appointment_id(reservation_id)
.delete()
)
return "Cancellation successful!"
@@ -0,0 +1,109 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from azure.identity import ClientSecretCredential
from booking_sample_settings import BookingSampleSettings
from bookings_plugin.bookings_plugin import BookingsPlugin
from msgraph import GraphServiceClient
from pydantic import ValidationError
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import (
OpenAIChatPromptExecutionSettings,
)
from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.kernel import Kernel
kernel = Kernel()
service_id = "open_ai"
ai_service = OpenAIChatCompletion(service_id=service_id, ai_model_id="gpt-3.5-turbo")
kernel.add_service(ai_service)
try:
booking_sample_settings = BookingSampleSettings()
except ValidationError as e:
raise ServiceInitializationError("Failed to initialize the booking sample settings.") from e
tenant_id = booking_sample_settings.tenant_id
client_id = booking_sample_settings.client_id
client_secret = booking_sample_settings.client_secret
client_secret_credential = ClientSecretCredential(tenant_id=tenant_id, client_id=client_id, client_secret=client_secret)
graph_client = GraphServiceClient(credentials=client_secret_credential, scopes=["https://graph.microsoft.com/.default"])
booking_business_id = booking_sample_settings.business_id
booking_service_id = booking_sample_settings.service_id
bookings_plugin = BookingsPlugin(
graph_client=graph_client,
booking_business_id=booking_business_id,
booking_service_id=booking_service_id,
)
kernel.add_plugin(bookings_plugin, "BookingsPlugin")
chat_function = kernel.add_function(
plugin_name="ChatBot",
function_name="Chat",
prompt="{{$chat_history}}{{$user_input}}",
template_format="semantic-kernel",
)
settings: OpenAIChatPromptExecutionSettings = kernel.get_prompt_execution_settings_from_service_id(
service_id, ChatCompletionClientBase
)
settings.max_tokens = 2000
settings.temperature = 0.1
settings.top_p = 0.8
settings.function_choice_behavior.Auto(filters={"exclude_plugin": ["ChatBot"]})
chat_history = ChatHistory(
system_message="When responding to the user's request to book a table, include the reservation ID."
)
async def chat() -> bool:
try:
user_input = input("User:> ")
except KeyboardInterrupt:
print("\n\nExiting chat...")
return False
except EOFError:
print("\n\nExiting chat...")
return False
if user_input == "exit":
print("\n\nExiting chat...")
return False
# Note the reservation returned contains an ID. That ID can be used to cancel the reservation,
# when the bookings API supports it.
answer = await kernel.invoke(
chat_function, KernelArguments(settings=settings, user_input=user_input, chat_history=chat_history)
)
chat_history.add_user_message(user_input)
chat_history.add_assistant_message(str(answer))
print(f"Assistant:> {answer}")
return True
async def main() -> None:
chatting = True
print(
"Welcome to your Restaurant Booking Assistant.\
\n Type 'exit' to exit.\
\n Please enter the following information to book a table: the restaurant, the date and time, \
\n the number of people, your name, phone, and email. You may ask me for help booking a table, \
\n listing reservations, or cancelling a reservation. When cancelling please provide the reservation ID."
)
while chatting:
chatting = await chat()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,8 @@
ACS_CONNECTION_STRING=
CALLBACK_URI_HOST=
AZURE_OPENAI_ENDPOINT=
AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME=
AZURE_OPENAI_API_VERSION=
AZURE_OPENAI_API_KEY=
+291
View File
@@ -0,0 +1,291 @@
# Copyright (c) Microsoft. All rights reserved.
####################################################################
# Sample Quart webapp with that connects to Azure OpenAI #
# Make sure to install `uv`, see: #
# https://docs.astral.sh/uv/getting-started/installation/ #
# and rename .env.example to .env and fill in the values. #
# Follow the guidance in README.md for more info. #
# To run the app, use: #
# `uv run --env-file .env call_automation.py` #
####################################################################
#
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "Quart",
# "azure-eventgrid",
# "azure-communication-callautomation==1.4.0b1",
# "semantic-kernel",
# ]
# ///
import asyncio
import base64
import logging
import os
import uuid
from datetime import datetime
from random import randint
from urllib.parse import urlencode, urlparse, urlunparse
from azure.communication.callautomation import (
AudioFormat,
MediaStreamingAudioChannelType,
MediaStreamingContentType,
MediaStreamingOptions,
MediaStreamingTransportType,
)
from azure.communication.callautomation.aio import CallAutomationClient
from azure.eventgrid import EventGridEvent, SystemEventNames
from azure.identity import AzureCliCredential
from numpy import ndarray
from quart import Quart, Response, json, request, websocket
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.open_ai import (
AzureRealtimeExecutionSettings,
AzureRealtimeWebsocket,
)
from semantic_kernel.connectors.ai.open_ai.services._open_ai_realtime import ListenEvents
from semantic_kernel.connectors.ai.realtime_client_base import RealtimeClientBase
from semantic_kernel.contents import AudioContent, RealtimeAudioEvent
from semantic_kernel.functions import kernel_function
# Callback events URI to handle callback events.
CALLBACK_URI_HOST = os.environ["CALLBACK_URI_HOST"]
CALLBACK_EVENTS_URI = CALLBACK_URI_HOST + "/api/callbacks"
acs_client = CallAutomationClient.from_connection_string(os.environ["ACS_CONNECTION_STRING"])
app = Quart(__name__)
# region: Semantic Kernel
kernel = Kernel()
class HelperPlugin:
"""Helper plugin for the Semantic Kernel."""
@kernel_function
def get_weather(self, location: str) -> str:
"""Get the weather for a location."""
app.logger.info(f"@ Getting weather for {location}")
weather_conditions = ("sunny", "hot", "cloudy", "raining", "freezing", "snowing")
weather = weather_conditions[randint(0, len(weather_conditions) - 1)] # nosec
return f"The weather in {location} is {weather}."
@kernel_function
def get_date_time(self) -> str:
"""Get the current date and time."""
app.logger.info("@ Getting current datetime")
return f"The current date and time is {datetime.now().isoformat()}."
@kernel_function
async def goodbye(self):
"""When the user is done, say goodbye and then call this function."""
app.logger.info("@ Goodbye has been called!")
global call_connection_id
await acs_client.get_call_connection(call_connection_id).hang_up(is_for_everyone=True)
kernel.add_plugin(plugin=HelperPlugin(), plugin_name="helpers", description="Helper functions for the realtime client.")
# region: Handlers for audio and data streams
async def from_realtime_to_acs(audio: ndarray):
"""Function that forwards the audio from the model to the websocket of the ACS client."""
app.logger.debug("Audio received from the model, sending to ACS client")
await websocket.send(
json.dumps({"kind": "AudioData", "audioData": {"data": base64.b64encode(audio.tobytes()).decode("utf-8")}})
)
async def from_acs_to_realtime(client: RealtimeClientBase):
"""Function that forwards the audio from the ACS client to the model."""
while True:
try:
# Receive data from the ACS client
stream_data = await websocket.receive()
data = json.loads(stream_data)
if data["kind"] == "AudioData":
# send it to the Realtime service
await client.send(
event=RealtimeAudioEvent(
audio=AudioContent(data=data["audioData"]["data"], data_format="base64", inner_content=data),
)
)
except Exception:
app.logger.info("Websocket connection closed.")
break
async def handle_realtime_messages(client: RealtimeClientBase):
"""Function that handles the messages from the Realtime service.
This function only handles the non-audio messages.
Audio is done through the callback so that it is faster and smoother.
"""
async for event in client.receive(audio_output_callback=from_realtime_to_acs):
match event.service_type:
case ListenEvents.SESSION_CREATED:
app.logger.info("Session Created Message")
app.logger.debug(f" Session Id: {event.service_event.session.id}")
case ListenEvents.ERROR:
app.logger.error(f" Error: {event.service_event.error}")
case ListenEvents.INPUT_AUDIO_BUFFER_CLEARED:
app.logger.info("Input Audio Buffer Cleared Message")
case ListenEvents.INPUT_AUDIO_BUFFER_SPEECH_STARTED:
app.logger.debug(f"Voice activity detection started at {event.service_event.audio_start_ms} [ms]")
await websocket.send(json.dumps({"Kind": "StopAudio", "AudioData": None, "StopAudio": {}}))
case ListenEvents.CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_COMPLETED:
app.logger.info(f" User:-- {event.service_event.transcript}")
case ListenEvents.CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_FAILED:
app.logger.error(f" Error: {event.service_event.error}")
case ListenEvents.RESPONSE_DONE:
app.logger.info("Response Done Message")
app.logger.debug(f" Response Id: {event.service_event.response.id}")
if event.service_event.response.status_details:
app.logger.debug(
f" Status Details: {event.service_event.response.status_details.model_dump_json()}"
)
case ListenEvents.RESPONSE_AUDIO_TRANSCRIPT_DONE:
app.logger.info(f" AI:-- {event.service_event.transcript}")
# region: Routes
# WebSocket.
@app.websocket("/ws")
async def ws():
app.logger.info("Client connected to WebSocket")
client = AzureRealtimeWebsocket(credential=AzureCliCredential())
settings = AzureRealtimeExecutionSettings(
instructions="""You are a chat bot. Your name is Mosscap and
you have one goal: figure out what people need.
Your full name, should you need to know it, is
Splendid Speckled Mosscap. You communicate
effectively, but you tend to answer with long
flowery prose.""",
turn_detection={"type": "server_vad"},
voice="shimmer",
input_audio_format="pcm16",
output_audio_format="pcm16",
input_audio_transcription={"model": "whisper-1"},
function_choice_behavior=FunctionChoiceBehavior.Auto(),
)
# create the realtime client session
async with client(settings=settings, create_response=True, kernel=kernel):
# start handling the messages from the realtime client
# and allow the callback to be used to forward the audio to the acs client
receive_task = asyncio.create_task(handle_realtime_messages(client))
# receive messages from the ACS client and send them to the realtime client
await from_acs_to_realtime(client)
receive_task.cancel()
@app.route("/api/incomingCall", methods=["POST"])
async def incoming_call_handler() -> Response:
for event_dict in await request.json:
event = EventGridEvent.from_dict(event_dict)
match event.event_type:
case SystemEventNames.EventGridSubscriptionValidationEventName:
app.logger.info("Validating subscription")
validation_code = event.data["validationCode"]
validation_response = {"validationResponse": validation_code}
return Response(response=json.dumps(validation_response), status=200)
case SystemEventNames.AcsIncomingCallEventName:
app.logger.debug("Incoming call received: data=%s", event.data)
caller_id = (
event.data["from"]["phoneNumber"]["value"]
if event.data["from"]["kind"] == "phoneNumber"
else event.data["from"]["rawId"]
)
app.logger.info("incoming call handler caller id: %s", caller_id)
incoming_call_context = event.data["incomingCallContext"]
guid = uuid.uuid4()
query_parameters = urlencode({"callerId": caller_id})
callback_uri = f"{CALLBACK_EVENTS_URI}/{guid}?{query_parameters}"
parsed_url = urlparse(CALLBACK_EVENTS_URI)
websocket_url = urlunparse(("wss", parsed_url.netloc, "/ws", "", "", ""))
app.logger.debug("callback url: %s", callback_uri)
app.logger.debug("websocket url: %s", websocket_url)
answer_call_result = await acs_client.answer_call(
incoming_call_context=incoming_call_context,
operation_context="incomingCall",
callback_url=callback_uri,
media_streaming=MediaStreamingOptions(
transport_url=websocket_url,
transport_type=MediaStreamingTransportType.WEBSOCKET,
content_type=MediaStreamingContentType.AUDIO,
audio_channel_type=MediaStreamingAudioChannelType.MIXED,
start_media_streaming=True,
enable_bidirectional=True,
audio_format=AudioFormat.PCM24_K_MONO,
),
)
app.logger.info(f"Answered call for connection id: {answer_call_result.call_connection_id}")
case _:
app.logger.debug("Event type not handled: %s", event.event_type)
app.logger.debug("Event data: %s", event.data)
return Response(status=200)
return Response(status=200)
@app.route("/api/callbacks/<contextId>", methods=["POST"])
async def callbacks(contextId):
for event in await request.json:
# Parsing callback events
global call_connection_id
event_data = event["data"]
call_connection_id = event_data["callConnectionId"]
app.logger.debug(
f"Received Event:-> {event['type']}, Correlation Id:-> {event_data['correlationId']}, CallConnectionId:-> {call_connection_id}" # noqa: E501
)
match event["type"]:
case "Microsoft.Communication.CallConnected":
call_connection_properties = await acs_client.get_call_connection(
call_connection_id
).get_call_properties()
media_streaming_subscription = call_connection_properties.media_streaming_subscription
app.logger.info(f"MediaStreamingSubscription:--> {media_streaming_subscription}")
app.logger.info(f"Received CallConnected event for connection id: {call_connection_id}")
app.logger.debug("CORRELATION ID:--> %s", event_data["correlationId"])
app.logger.debug("CALL CONNECTION ID:--> %s", event_data["callConnectionId"])
case "Microsoft.Communication.MediaStreamingStarted" | "Microsoft.Communication.MediaStreamingStopped":
app.logger.debug(
f"Media streaming content type:--> {event_data['mediaStreamingUpdate']['contentType']}"
)
app.logger.debug(
f"Media streaming status:--> {event_data['mediaStreamingUpdate']['mediaStreamingStatus']}"
)
app.logger.debug(
f"Media streaming status details:--> {event_data['mediaStreamingUpdate']['mediaStreamingStatusDetails']}" # noqa: E501
)
case "Microsoft.Communication.MediaStreamingFailed":
app.logger.warning(
f"Code:->{event_data['resultInformation']['code']}, Subcode:-> {event_data['resultInformation']['subCode']}" # noqa: E501
)
app.logger.warning(f"Message:->{event_data['resultInformation']['message']}")
case "Microsoft.Communication.CallDisconnected":
app.logger.debug(f"Call disconnected for connection id: {call_connection_id}")
return Response(status=200)
@app.route("/")
def home():
return "Hello SKxACS CallAutomation!"
# region: Main
if __name__ == "__main__":
app.logger.setLevel(logging.INFO)
app.run(port=8080)
@@ -0,0 +1,92 @@
# Call Automation - Quick Start Sample
This is a sample application. It highlights an integration of Azure Communication Services with Semantic Kernel, using the Azure OpenAI Service to enable intelligent conversational agents.
Original code for this sample can be found [here](https://github.com/Azure-Samples/communication-services-python-quickstarts/tree/main/callautomation-openai-sample).
## Prerequisites
- An Azure account with an active subscription. [Create an account for free](https://azure.microsoft.com/free/?WT.mc_id=A261C142F).
- A deployed Communication Services resource. [Create a Communication Services resource](https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource).
- A [phone number](https://learn.microsoft.com/en-us/azure/communication-services/quickstarts/telephony/get-phone-number) in your Azure Communication Services resource that can get inbound calls. NOTE: although trial phone numbers are available in free subscriptions, they will not work properly. You must have a paid phone number.
- [Python](https://www.python.org/downloads/) 3.9 or above.
- An Azure OpenAI Resource and Deployed Model. See [instructions](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal).
- Install `uv`, see [the uv docs](https://docs.astral.sh/uv/getting-started/installation/).
## To run the app
1. Open an instance of PowerShell, Windows Terminal, Command Prompt or equivalent and navigate to the directory that you would like to clone the sample to.
2. git clone `https://github.com/microsoft/semantic-kernel.git`.
3. Navigate to `python/samples/demos/call_automation` folder
### Setup and host your Azure DevTunnel
[Azure DevTunnels](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/overview) is an Azure service that enables you to share local web services hosted on the internet. Use the commands below to connect your local development environment to the public internet. This creates a tunnel with a persistent endpoint URL and which allows anonymous access. We will then use this endpoint to notify your application of calling events from the ACS Call Automation service.
```bash
devtunnel create --allow-anonymous
devtunnel port create -p 8080
devtunnel host
```
### Configuring application
Copy the `.env.example` file to `.env` and update the following values:
1. `ACS_CONNECTION_STRING`: Azure Communication Service resource's connection string.
2. `CALLBACK_URI_HOST`: Base url of the app. (For local development use the dev tunnel url from the step above). This URI is in the form of `https://<unique-id>-8080.XXXX.devtunnels.ms`.
3. `AZURE_OPENAI_ENDPOINT`: Azure Open AI service endpoint
4. `AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME`: Azure Open AI deployment name
5. `AZURE_OPENAI_API_VERSION`: Azure Open AI API version, this should be one that includes the realtime api, for instance '2024-10-01-preview'. This API version is the currently the only supported version.
6. `AZURE_OPENAI_API_KEY`: Azure Open AI API key, optionally, you can also use Entra Auth.
## Run the app
1. Navigate to `call_automation` folder and do one of the following to start the main application:
- run `call_automation.py` in debug mode from your IDE (VSCode will load your .env variables into the environment automatically, other IDE's might need an extra step).
- execute `uv run --env-file .env call_automation.py` directly in your terminal (this uses `uv`, which will then install the requirements in a temporary virtual environment, see [uv docs](https://docs.astral.sh/uv/guides/scripts) for more info).
2. Browser should pop up with a simple page. If not navigate it to `http://localhost:8080/` or your dev tunnel url.
3. Register an EventGrid Webhook for the IncomingCall(`https://<devtunnelurl>/api/incomingCall`) event that points to your devtunnel URI. Instructions [here](https://learn.microsoft.com/en-us/azure/communication-services/concepts/call-automation/incoming-call-notification).
- To register the event, navigate to your ACS resource in the Azure Portal (follow the Microsoft Learn Docs if you prefer to use the CLI).
- On the left menu bar click "Events."
- Click on "+Event Subscription."
- Provide a unique name for the event subscription details, for example, "IncomingCallWebhook"
- Leave the "Event Schema" as "Event Grid Schema"
- Provide a unique "System Topic Name"
- For the "Event Types" select "Incoming Call"
- For the "Endpoint Details" select "Webhook" from the drop down
- Once "Webhook" is selected, you will need to configure the URI for the incoming call webhook, as mentioned above: `https://<devtunnelurl>/api/incomingCall`.
- **Important**: before clicking on "Create" to create the event subscription, the `call_automation.py` script must be running, as well as your devtunnel. ACS sends a verification payload to the app to make sure that the communication is configured properly. The event subscription will not succeed in the portal without the script running. If you see an error, this is most likely the root cause.
Once that's completed you should have a running application. The way to test this is to place a call to your ACS phone number and talk to your intelligent agent!
In the terminal you should see all sorts of logs from both ACS and Semantic Kernel. Successful logs can look like the following:
```bash
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: incoming call handler caller id: +14255551234
[2YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: callback url: https://<devtunnelurl>/api/callbacks/<guid>?callerId=%2B14257059063
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: websocket url: wss://<devtunnelurl>/ws
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Answered call for connection id: <guid>>
[YYYY-MM-DD HH:MM:SS,mmm] [28438] [INFO] 127.0.0.1:55481 POST /api/incomingCall 1.1 200 - 595851
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Client connected to WebSocket
[YYYY-MM-DD HH:MM:SS,mmm] [28438] [INFO] 127.0.0.1:55483 GET /ws 1.1 101 - 1262939
Session Created Message
Session Id: sess_BG0CBnM6CMEGSbpsK5CiC
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Received Event:-> Microsoft.Communication.ParticipantsUpdated, Correlation Id:-> <guid>, CallConnectionId:-> <guid>
[YYYY-MM-DD HH:MM:SS,mmm] [28438] [INFO] 127.0.0.1:55486 POST /api/callbacks/1629809d 1.1 200 - 1642
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Received Event:-> Microsoft.Communication.CallConnected, Correlation Id:-> <guid>, CallConnectionId:-> <guid>
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Received Event:-> Microsoft.Communication.MediaStreamingStarted, Correlation Id:-> <guid>, CallConnectionId:-> <guid>
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Media streaming content type:--> Audio
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Media streaming status:--> mediaStreamingStarted
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Media streaming status details:--> subscriptionStarted
[YYYY-MM-DD HH:MM:SS,mmm] [28438] [INFO] 127.0.0.1:55488 POST /api/callbacks/1629809d 1.1 200 - 1490
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: MediaStreamingSubscription:--> {'additional_properties': {}, 'id': '50a8ca48', 'state': 'active', 'subscribed_content_types': ['audio']}
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: Received CallConnected event for connection id: <guid>
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: CORRELATION ID:--> <guid>
[YYYY-MM-DD HH:MM:SS,mmm] INFO in call_automation: CALL CONNECTION ID:--> <guid>
[YYYY-MM-DD HH:MM:SS,mmm] [28438] [INFO] 127.0.0.1:55487 POST /api/callbacks/1629809d 1.1 200 - 137171
AI:-- Ah, greetings, dear traveler of the world! In this moment, amidst the tapestry of time and space, you seek a glimpse of the weather that graces a particular place. Pray, tell me, to which location do you wish to turn your gaze, so I might summon the winds and the skies to unfold their secrets for you?
Response Done Message
Response Id: resp_BG0CBedSOZgrlo82IlxMC
```
@@ -0,0 +1,2 @@
BOT_SECRET="copy from Copilot Studio Agent, under Settings > Security > Web Channel"
BOT_ENDPOINT="https://europe.directline.botframework.com/v3/directline"
@@ -0,0 +1,50 @@
# Copilot Studio Agents interaction
This is a simple example of how to interact with Copilot Studio Agents as they were first-party agents in Semantic Kernel.
![alt text](image.png)
## Rationale
Semantic Kernel already features many different types of agents, including `ChatCompletionAgent`, `AzureAIAgent`, `OpenAIAssistantAgent` or `AutoGenConversableAgent`. All of them though involve code-based agents.
Instead, [Microsoft Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/fundamentals-what-is-copilot-studio) allows you to create declarative, low-code, and easy-to-maintain agents and publish them over multiple channels.
This way, you can create any amount of agents in Copilot Studio and interact with them along with code-based agents in Semantic Kernel, thus being able to use the best of both worlds.
## Implementation
The implementation is quite simple, since Copilot Studio can publish agents over DirectLine API, which we can use in Semantic Kernel to define a new subclass of `Agent` named [`DirectLineAgent`](src/direct_line_agent.py).
Additionally, we do enforce [authentication to the DirectLine API](https://learn.microsoft.com/en-us/microsoft-copilot-studio/configure-web-security).
## Usage
> [!NOTE]
> Working with Copilot Studio Agents requires a [subscription](https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-licensing-subscriptions) to Microsoft Copilot Studio.
> [!TIP]
> In this case, we suggest to start with a simple Q&A Agent and supply a PDF to answer some questions. You can find a free sample like [Microsoft Surface Pro 4 User Guide](https://download.microsoft.com/download/2/9/B/29B20383-302C-4517-A006-B0186F04BE28/surface-pro-4-user-guide-EN.pdf)
1. [Create a new agent](https://learn.microsoft.com/en-us/microsoft-copilot-studio/fundamentals-get-started?tabs=web) in Copilot Studio
2. [Publish the agent](https://learn.microsoft.com/en-us/microsoft-copilot-studio/publication-fundamentals-publish-channels?tabs=web)
3. Turn off default authentication under the agent Settings > Security
4. [Setup web channel security](https://learn.microsoft.com/en-us/microsoft-copilot-studio/configure-web-security) and copy the secret value
Once you're done with the above steps, you can use the following code to interact with the Copilot Studio Agent:
1. Copy the `.env.sample` file to `.env` and set the `BOT_SECRET` environment variable to the secret value
2. Run the following code:
```bash
python -m venv .venv
# On Mac/Linux
source .venv/bin/activate
# On Windows
.venv\Scripts\Activate.ps1
pip install -r requirements.txt
chainlit run --port 8081 .\chat.py
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

@@ -0,0 +1,44 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import os
import chainlit as cl
from direct_line_agent import DirectLineAgent
from dotenv import load_dotenv
from semantic_kernel.contents.chat_history import ChatHistory
load_dotenv(override=True)
logging.basicConfig(level=logging.INFO)
logging.getLogger("direct_line_agent").setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)
agent = DirectLineAgent(
id="copilot_studio",
name="copilot_studio",
description="copilot_studio",
bot_secret=os.getenv("BOT_SECRET"),
bot_endpoint=os.getenv("BOT_ENDPOINT"),
)
@cl.on_chat_start
async def on_chat_start():
cl.user_session.set("chat_history", ChatHistory())
@cl.on_message
async def on_message(message: cl.Message):
chat_history: ChatHistory = cl.user_session.get("chat_history")
chat_history.add_user_message(message.content)
response = await agent.get_response(history=chat_history)
cl.user_session.set("chat_history", chat_history)
logger.info(f"Response: {response}")
await cl.Message(content=response.content, author=agent.name).send()
@@ -0,0 +1,236 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import sys
from collections.abc import AsyncIterable
from typing import Any
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
import aiohttp
from semantic_kernel.agents import Agent
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException
from semantic_kernel.utils.telemetry.agent_diagnostics.decorators import (
trace_agent_get_response,
trace_agent_invocation,
)
logger = logging.getLogger(__name__)
class DirectLineAgent(Agent):
"""
An Agent subclass that connects to a DirectLine Bot from Microsoft Bot Framework.
Instead of directly supplying a secret and conversation ID, the agent queries a token_endpoint
to retrieve the token and then starts a conversation.
"""
token_endpoint: str | None = None
bot_secret: str | None = None
bot_endpoint: str
conversation_id: str | None = None
directline_token: str | None = None
session: aiohttp.ClientSession = None
async def _ensure_session(self) -> None:
"""
Lazily initialize the aiohttp ClientSession.
"""
if self.session is None:
self.session = aiohttp.ClientSession()
async def _fetch_token_and_conversation(self) -> None:
"""
Retrieve the DirectLine token either by using the bot_secret or by querying the token_endpoint.
If bot_secret is provided, it posts to "https://directline.botframework.com/v3/directline/tokens/generate".
"""
await self._ensure_session()
try:
if self.bot_secret:
url = f"{self.bot_endpoint}/tokens/generate"
headers = {"Authorization": f"Bearer {self.bot_secret}"}
async with self.session.post(url, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
self.directline_token = data.get("token")
if not self.directline_token:
logger.error("Token generation response missing token: %s", data)
raise AgentInvokeException("No token received from token generation.")
else:
logger.error("Token generation endpoint error status: %s", resp.status)
raise AgentInvokeException("Failed to generate token using bot_secret.")
else:
async with self.session.get(self.token_endpoint) as resp:
if resp.status == 200:
data = await resp.json()
self.directline_token = data.get("token")
if not self.directline_token:
logger.error("Token endpoint returned no token: %s", data)
raise AgentInvokeException("No token received.")
else:
logger.error("Token endpoint error status: %s", resp.status)
raise AgentInvokeException("Failed to fetch token from token endpoint.")
except Exception as ex:
logger.exception("Exception fetching token: %s", ex)
raise AgentInvokeException("Exception occurred while fetching token.") from ex
@trace_agent_get_response
@override
async def get_response(
self,
history: ChatHistory,
arguments: dict[str, Any] | None = None,
**kwargs: Any,
) -> ChatMessageContent:
"""
Get a response from the DirectLine Bot.
"""
responses = []
async for response in self.invoke(history, arguments, **kwargs):
responses.append(response)
if not responses:
raise AgentInvokeException("No response from DirectLine Bot.")
return responses[0]
@trace_agent_invocation
@override
async def invoke(
self,
history: ChatHistory,
arguments: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[ChatMessageContent]:
"""
Send the latest message from the chat history to the DirectLine Bot
and yield responses. This sends the payload after ensuring that:
1. The token is fetched.
2. A conversation is started.
3. The activity payload is posted.
4. Activities are polled until an event "DynamicPlanFinished" is received.
"""
payload = self._build_payload(history, arguments, **kwargs)
response_data = await self._send_message(payload)
if response_data is None or "activities" not in response_data:
raise AgentInvokeException(f"Invalid response from DirectLine Bot.\n{response_data}")
logger.debug("DirectLine Bot response: %s", response_data)
# NOTE DirectLine Activities have different formats
# than ChatMessageContent. We need to convert them and
# remove unsupported activities.
for activity in response_data["activities"]:
if activity.get("type") != "message" or activity.get("from", {}).get("role") == "user":
continue
role = activity.get("from", {}).get("role", "assistant")
if role == "bot":
role = "assistant"
message = ChatMessageContent(
role=role,
content=activity.get("text", ""),
name=activity.get("from", {}).get("name", self.name),
)
yield message
def _build_payload(
self,
history: ChatHistory,
arguments: dict[str, Any] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""
Build the message payload for the DirectLine Bot.
Uses the latest message from the chat history.
"""
latest_message = history.messages[-1] if history.messages else None
text = latest_message.content if latest_message else "Hello"
payload = {
"type": "message",
"from": {"id": "user"},
"text": text,
}
# Optionally include conversationId if available.
if self.conversation_id:
payload["conversationId"] = self.conversation_id
return payload
async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any] | None:
"""
1. Ensure the token is fetched.
2. Start a conversation by posting to the bot_endpoint /conversations endpoint (without a payload)
3. Post the payload to /conversations/{conversationId}/activities
4. Poll GET /conversations/{conversationId}/activities every 1s using a watermark
to fetch only the latest messages until an activity with type="event"
and name="DynamicPlanFinished" is found.
"""
await self._ensure_session()
if not self.directline_token:
await self._fetch_token_and_conversation()
headers = {
"Authorization": f"Bearer {self.directline_token}",
"Content-Type": "application/json",
}
# Step 2: Start a conversation if one hasn't already been started.
if not self.conversation_id:
start_conv_url = f"{self.bot_endpoint}/conversations"
async with self.session.post(start_conv_url, headers=headers) as resp:
if resp.status not in (200, 201):
logger.error("Failed to start conversation. Status: %s", resp.status)
raise AgentInvokeException("Failed to start conversation.")
conv_data = await resp.json()
self.conversation_id = conv_data.get("conversationId")
if not self.conversation_id:
raise AgentInvokeException("Conversation ID not found in start response.")
# Step 3: Post the message payload.
activities_url = f"{self.bot_endpoint}/conversations/{self.conversation_id}/activities"
async with self.session.post(activities_url, json=payload, headers=headers) as resp:
if resp.status != 200:
logger.error("Failed to post activity. Status: %s", resp.status)
raise AgentInvokeException("Failed to post activity.")
_ = await resp.json() # Response from posting activity is ignored.
# Step 4: Poll for new activities using watermark until DynamicPlanFinished event is found.
finished = False
collected_data = None
watermark = None
while not finished:
url = activities_url if watermark is None else f"{activities_url}?watermark={watermark}"
async with self.session.get(url, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
watermark = data.get("watermark", watermark)
activities = data.get("activities", [])
if any(
activity.get("type") == "event" and activity.get("name") == "DynamicPlanFinished"
for activity in activities
):
collected_data = data
finished = True
break
else:
logger.error("Error polling activities. Status: %s", resp.status)
await asyncio.sleep(0.3)
return collected_data
async def close(self) -> None:
"""
Clean up the aiohttp session.
"""
await self.session.close()
# NOTE not implemented yet, possibly use websockets
@trace_agent_invocation
@override
async def invoke_stream(self, *args, **kwargs):
return super().invoke_stream(*args, **kwargs)
@@ -0,0 +1,4 @@
chainlit>=2.0.1
python-dotenv>=1.0.1
aiohttp>=3.10.5
semantic-kernel>=1.22.0
@@ -0,0 +1,99 @@
# Extend Copilot Studio with Semantic Kernel
This template demonstrates how to build a [Copilot Studio Skill](https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-use-skills) that allows to extend agent capabilities with a custom API running in Azure with the help of the Semantic Kernel.
![Copilot Studio using the Semantic Kernel skill within a topic](image.png)
## Rationale
[Microsoft Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/fundamentals-what-is-copilot-studio) is a graphical, low-code tool for both creating an agent — including building automation with Power Automate — and extending a Microsoft 365 Copilot with your own enterprise data and scenarios.
However, in some cases you may need to extend the default agent capabilities by leveraing a pro-code approach, where specific requirements apply.
## Prerequisites
- Azure Subscription
- Azure CLI
- Azure Developer CLI
- Python 3.12 or later
- A Microsoft 365 tenant with Copilot Studio enabled
> [!NOTE]
> You don't need the Azure subscription to be on the same tenant as the Microsoft 365 tenant where Copilot Studio is enabled.
>
> However, you need to have the necessary permissions to register an application in the Azure Active Directory of the tenant where Copilot Studio is enabled.
## Getting Started
1. Clone this repository to your local machine.
```bash
git clone https://github.com/microsoft/semantic-kernel
cd semantic-kernel/python/samples/demos/copilot_studio_skill
```
2. Create a App Registration in Azure Entra ID, with a client secret.
```powershell
az login --tenant <COPILOT-tenant-id>
$appId = az ad app create --display-name "SKCopilotSkill" --query appId -o tsv
$secret = az ad app credential reset --id $appId --append --query password -o tsv
```
4. Run `azd up` to deploy the Azure resources.
```bash
azd auth login --tenant <AZURE-tenant-id>
azd up
```
> [!NOTE]
> When prompted, provide the `botAppId`, `botPassword` and `botTenantId` values from above.
>
> You will also need to input and existing Azure OpenAI resource name and its resource group.
> [!TIP]
> Once the deployment is complete, you can find the URL of the deployed API in the `output` section of the Azure Developer CLI. Copy this URL.
5. Ensure the App Registration `homeUrl` is set to the URL of the deployed API. This is required for the bot to be able to respond to requests from Copilot Studio.
6. Register the bot in Copilot Studio as skill
- Open the Copilot Studio in your Microsoft 365 tenant.
- Create a new agent or reuse an existing one.
- Go to "Settings" in the upper right corner of the agent page.
- Go to the "Skills" tab and click on "Add a skill".
- Now input as URL `API_URL/manifest` where `API_URL` is the URL of the deployed API.
- Click on "Next" to register the skill.
- Once the skill is registered, you can [start using it in your agent](https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-use-skills). Edit a Topic or create a new one, and add the skill as a node to the topic flow.
## Architecture
The architecture features `Azure Bot Service` as the main entry point for the requests. The bot service is responsible for routing the requests to the appropriate backend service, which in this case is a custom API running in `Azure Container Apps` leveraging Semantic Kernel.
Below is the updated markdown content with the new call included:
```mermaid
flowchart LR
subgraph Clients
A[Copilot Studio]
end
C[Azure Bot Service]
D["SK App<br/>(Azure Container Apps)"]
A -- "Initiates Request" --> C
C -- "Forwards Request" --> D
D -- "Processes & Returns Response" --> C
C -- "Routes Response" --> A
%% Una tantum call to fetch manifest directly from SK App
A -- "Fetch Manifest" --> D
```
### Implementation
Please refer to the original [Bot Framework documentation](https://learn.microsoft.com/en-us/azure/bot-service/skill-implement-skill?view=azure-bot-service-4.0&tabs=python) for more details on how to implement the bot service skill and the custom API.
> [!NOTE]
> As of today, Bot Framework SDK offers only `aiohttp` support for Python.
@@ -0,0 +1,11 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: azure-bot
services:
api:
project: src/api
host: containerapp
language: python
docker:
path: dockerfile
remoteBuild: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

@@ -0,0 +1,115 @@
param uniqueId string
param prefix string
param userAssignedIdentityResourceId string
param userAssignedIdentityClientId string
param openAiEndpoint string
param openAiApiKey string
param openAiApiVersion string = '2024-08-01-preview'
param openAiModel string = 'gpt-4o'
param applicationInsightsConnectionString string
param containerRegistry string = '${prefix}acr${uniqueId}'
param location string = resourceGroup().location
param logAnalyticsWorkspaceName string
param apiAppExists bool
param emptyContainerImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
param botAppId string
@secure()
param botPassword string
param botTenantId string
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
name: logAnalyticsWorkspaceName
}
// see https://azureossd.github.io/2023/01/03/Using-Managed-Identity-and-Bicep-to-pull-images-with-Azure-Container-Apps/
resource containerAppEnv 'Microsoft.App/managedEnvironments@2023-11-02-preview' = {
name: '${prefix}-containerAppEnv-${uniqueId}'
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${userAssignedIdentityResourceId}': {}
}
}
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalyticsWorkspace.properties.customerId
sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
}
}
}
}
// When azd passes parameters, it will tell if apps were already created
// In this case, we don't overwrite the existing image
// See https://johnnyreilly.com/using-azd-for-faster-incremental-azure-container-app-deployments-in-azure-devops#the-does-your-service-exist-parameter
module fetchLatestImageApi './fetch-container-image.bicep' = {
name: 'api-app-image'
params: {
exists: apiAppExists
name: '${prefix}-api-${uniqueId}'
}
}
resource apiContainerApp 'Microsoft.App/containerApps@2023-11-02-preview' = {
name: '${prefix}-api-${uniqueId}'
location: location
tags: { 'azd-service-name': 'api' }
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${userAssignedIdentityResourceId}': {}
}
}
properties: {
managedEnvironmentId: containerAppEnv.id
configuration: {
activeRevisionsMode: 'Single'
ingress: {
external: true
targetPort: 80
transport: 'auto'
}
registries: [
{
server: '${containerRegistry}.azurecr.io'
identity: userAssignedIdentityResourceId
}
]
}
template: {
scale: {
minReplicas: 1
maxReplicas: 1
}
containers: [
{
name: 'api'
image: apiAppExists ? fetchLatestImageApi.outputs.containers[0].image : emptyContainerImage
resources: {
cpu: 1
memory: '2Gi'
}
env: [
{ name: 'AZURE_CLIENT_ID', value: userAssignedIdentityClientId }
{ name: 'BOT_APP_ID', value: botAppId }
{ name: 'BOT_PASSWORD', value: botPassword }
{ name: 'BOT_TENANT_ID', value: botTenantId }
{ name: 'APPLICATIONINSIGHTS_CONNECTIONSTRING', value: applicationInsightsConnectionString }
{ name: 'APPLICATIONINSIGHTS_SERVICE_NAME', value: 'api' }
{ name: 'AZURE_OPENAI_ENDPOINT', value: openAiEndpoint }
{ name: 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME', value: openAiModel }
{ name: 'AZURE_OPENAI_API_KEY', value: '' }
{ name: 'AZURE_OPENAI_API_VERSION', value: openAiApiVersion }
]
}
]
}
}
}
output messagesEndpoint string = 'https://${apiContainerApp.properties.configuration.ingress.fqdn}/api/messages'
output manifestUrl string = 'https://${apiContainerApp.properties.configuration.ingress.fqdn}/manifest'
output homeUrl string = 'https://${apiContainerApp.properties.configuration.ingress.fqdn}'
@@ -0,0 +1,29 @@
param uniqueId string
param prefix string
param userAssignedIdentityPrincipalId string
param acrName string = '${prefix}acr${uniqueId}'
param location string = resourceGroup().location
resource acr 'Microsoft.ContainerRegistry/registries@2021-06-01-preview' = {
name: acrName
location: location
sku: {
name: 'Standard' // Choose between Basic, Standard, and Premium based on your needs
}
properties: {
adminUserEnabled: false
}
}
resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
name: guid(acr.id, userAssignedIdentityPrincipalId, 'acrpull')
scope: acr
properties: {
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') // Role definition ID for AcrPull
principalId: userAssignedIdentityPrincipalId
principalType: 'ServicePrincipal'
}
}
output acrName string = acrName
output acrEndpoint string = acr.properties.loginServer
@@ -0,0 +1,46 @@
param uniqueId string
param prefix string
@secure()
param userAssignedIdentityPrincipalId string
param location string = resourceGroup().location
param appInsightsName string = '${prefix}-appin-${uniqueId}'
param logAnalyticsWorkspaceName string = '${prefix}-law-${uniqueId}'
// Create or reference an existing Log Analytics Workspace
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' = {
name: logAnalyticsWorkspaceName
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}
// Create Application Insights resource linked to the Log Analytics Workspace
resource applicationInsights 'Microsoft.Insights/components@2020-02-02-preview' = {
name: appInsightsName
location: location
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: logAnalyticsWorkspace.id
}
}
// Assign "Monitoring Metrics Publisher" role to the Application Insights resource
resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
name: guid(applicationInsights.id, userAssignedIdentityPrincipalId, 'appinsightsPublisher')
scope: applicationInsights
properties: {
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') // Role definition ID for Monitoring Metrics Publisher
principalId: userAssignedIdentityPrincipalId
principalType: 'ServicePrincipal'
}
}
output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id
output logAnalyticsWorkspaceName string = logAnalyticsWorkspaceName
output applicationInsightsInstrumentationKey string = applicationInsights.properties.InstrumentationKey
output applicationInsightsConnectionString string = applicationInsights.properties.ConnectionString
@@ -0,0 +1,37 @@
param uniqueId string
param prefix string
param messagesEndpoint string
param botAppId string
param botTenantId string
resource bot 'Microsoft.BotService/botServices@2023-09-15-preview' = {
name: '${prefix}bot${uniqueId}'
location: 'global'
sku: {
name: 'F0'
}
kind: 'azurebot'
properties: {
iconUrl: 'https://docs.botframework.com/static/devportal/client/images/bot-framework-default.png'
displayName: '${prefix}bot${uniqueId}'
endpoint: messagesEndpoint
description: 'Bot created by Bicep'
publicNetworkAccess: 'Enabled'
msaAppId: botAppId
msaAppTenantId: botTenantId
msaAppType: 'SingleTenant'
msaAppMSIResourceId: null
schemaTransformationVersion: '1.3'
isStreamingSupported: false
}
}
// Connect the bot service to Microsoft Teams
resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = {
parent: bot
location: 'global'
name: 'MsTeamsChannel'
properties: {
channelName: 'MsTeamsChannel'
}
}
@@ -0,0 +1,8 @@
param exists bool
param name string
resource existingApp 'Microsoft.App/containerApps@2023-05-01' existing = if (exists) {
name: name
}
output containers array = exists ? existingApp.properties.template.containers : []
@@ -0,0 +1,141 @@
targetScope = 'subscription'
@minLength(1)
@maxLength(64)
@description('Name of the environment that can be used as part of naming resource convention')
param environmentName string
@description('The current user ID, to assign RBAC permissions to')
param currentUserId string
// Main deployment parameters
param prefix string = 'copstsk'
@minLength(1)
@description('Primary location for all resources')
param location string
@minLength(1)
@description('Name of the Azure OpenAI resource')
param openAIName string
@minLength(1)
@description('Name of the Azure Resource Group where the OpenAI resource is located')
param openAIResourceGroupName string
@description('Azure Bot app ID')
param botAppId string
@description('Azure Bot app password')
@secure()
param botPassword string
@description('Azure Bot tenant ID')
param botTenantId string
param openAIModel string
param openAIApiVersion string
param apiAppExists bool = false
param runningOnGh string = ''
var tags = {
'azd-env-name': environmentName
}
resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = {
name: 'rg-${environmentName}'
location: location
tags: tags
}
var uniqueId = uniqueString(rg.id)
var principalType = empty(runningOnGh) ? 'User' : 'ServicePrincipal'
module uami './uami.bicep' = {
name: 'uami'
scope: rg
params: {
uniqueId: uniqueId
prefix: prefix
location: location
}
}
module appin './appin.bicep' = {
name: 'appin'
scope: rg
params: {
uniqueId: uniqueId
prefix: prefix
location: location
userAssignedIdentityPrincipalId: uami.outputs.principalId
}
}
module acrModule './acr.bicep' = {
name: 'acr'
scope: rg
params: {
uniqueId: uniqueId
prefix: prefix
userAssignedIdentityPrincipalId: uami.outputs.principalId
location: location
}
}
module openAI './openAI.bicep' = {
name: 'openAI'
scope: resourceGroup(openAIResourceGroupName)
params: {
openAIName: openAIName
userAssignedIdentityPrincipalId: uami.outputs.principalId
}
}
module aca './aca.bicep' = {
name: 'aca'
scope: rg
params: {
uniqueId: uniqueId
prefix: prefix
userAssignedIdentityResourceId: uami.outputs.identityId
containerRegistry: acrModule.outputs.acrName
location: location
logAnalyticsWorkspaceName: appin.outputs.logAnalyticsWorkspaceName
applicationInsightsConnectionString: appin.outputs.applicationInsightsConnectionString
openAiApiKey: '' // Force ManId, otherwise set openAI.listKeys().key1
openAiEndpoint: openAI.outputs.openAIEndpoint
openAiModel: openAIModel
openAiApiVersion: openAIApiVersion
userAssignedIdentityClientId: uami.outputs.clientId
apiAppExists: apiAppExists
botAppId: botAppId
botPassword: botPassword
botTenantId: botTenantId
}
}
module bot 'bot.bicep' = {
name: 'bot'
scope: rg
params: {
uniqueId: uniqueId
prefix: prefix
botAppId: botAppId
botTenantId: botTenantId
messagesEndpoint: aca.outputs.messagesEndpoint
}
}
// These outputs are copied by azd to .azure/<env name>/.env file
// post provision script will use these values, too
output AZURE_RESOURCE_GROUP string = rg.name
output APPLICATIONINSIGHTS_CONNECTIONSTRING string = appin.outputs.applicationInsightsConnectionString
output AZURE_TENANT_ID string = subscription().tenantId
output AZURE_USER_ASSIGNED_IDENTITY_ID string = uami.outputs.identityId
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acrModule.outputs.acrEndpoint
output AZURE_OPENAI_MODEL string = openAIModel
output AZURE_OPENAI_ENDPOINT string = openAI.outputs.openAIEndpoint
output AZURE_OPENAI_API_VERSION string = openAIApiVersion
output ENDPOINT_URL string = aca.outputs.messagesEndpoint
output MANIFEST_URL string = aca.outputs.manifestUrl
output HOME_URL string = aca.outputs.homeUrl
output BOT_APP_ID string = botAppId
output BOT_TENANT_ID string = botTenantId
@@ -0,0 +1,42 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"environmentName": {
"value": "${AZURE_ENV_NAME}"
},
"currentUserId": {
"value": "${AZURE_PRINCIPAL_ID}"
},
"runningOnGh": {
"value": "${GITHUB_ACTIONS}"
},
"location": {
"value": "${AZURE_LOCATION}"
},
"openAiName": {
"value": "${AZURE_OPENAI_NAME}"
},
"openAiResourceGroupName": {
"value": "${AZURE_OPENAI_RG}"
},
"openAIModel": {
"value": "${AZURE_OPENAI_MODEL=gpt-4o}"
},
"openAIApiVersion": {
"value": "${AZURE_OPENAI_API_VERSION=2024-08-01-preview}"
},
"apiAppExists": {
"value": "${SERVICE_API_RESOURCE_EXISTS=false}"
},
"botAppId": {
"value": "${BOT_APPID}"
},
"botPassword": {
"value": "${BOT_PASSWORD}"
},
"botTenantId": {
"value": "${BOT_TENANT_ID}"
}
}
}
@@ -0,0 +1,20 @@
targetScope = 'resourceGroup'
param openAIName string
param userAssignedIdentityPrincipalId string
resource openAI 'Microsoft.CognitiveServices/accounts@2022-03-01' existing = {
name: openAIName
}
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
name: guid(openAI.id, userAssignedIdentityPrincipalId, 'Cognitive Services OpenAI User')
scope: openAI
properties: {
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Role definition ID for Cognitive Services OpenAI User
principalId: userAssignedIdentityPrincipalId
principalType: 'ServicePrincipal'
}
}
output openAIEndpoint string = openAI.properties.endpoint
@@ -0,0 +1,13 @@
param uniqueId string
param prefix string
param location string = resourceGroup().location
param identityName string = '${prefix}uami${uniqueId}'
resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = {
name: identityName
location: location
}
output identityId string = userAssignedIdentity.id
output clientId string = userAssignedIdentity.properties.clientId
output principalId string = userAssignedIdentity.properties.principalId
@@ -0,0 +1,47 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*.pyd
# Caches of various types
.cache/
.pip/
# Development environments
.env
.venv/
venv/
ENV/
# Version control
.git/
.gitignore
.github/
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Project backups
*.bak
# Log files
*.log
# OS generated files
.DS_Store
Thumbs.db
# Editor directories and files
.idea/
.vscode/
*.swp
*.swo
*~
@@ -0,0 +1,74 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
import traceback
from botbuilder.core import (
MessageFactory,
TurnContext,
)
from botbuilder.integration.aiohttp import (
CloudAdapter,
ConfigurationBotFrameworkAuthentication,
)
from botbuilder.schema import Activity, ActivityTypes, InputHints
class AdapterWithErrorHandler(CloudAdapter):
def __init__(
self,
settings: ConfigurationBotFrameworkAuthentication,
):
super().__init__(settings)
self.on_turn_error = self._handle_turn_error
async def _handle_turn_error(self, turn_context: TurnContext, error: Exception):
# This check writes out errors to console log
# 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()
await self._send_error_message(turn_context, error)
await self._send_eoc_to_parent(turn_context, error)
async def _send_error_message(self, turn_context: TurnContext, error: Exception):
try:
# Send a message to the user.
error_message_text = "The skill encountered an error or bug."
error_message = MessageFactory.text(error_message_text, error_message_text, InputHints.ignoring_input)
await turn_context.send_activity(error_message)
error_message_text = "To continue to run this bot, please fix the bot source code."
error_message = MessageFactory.text(error_message_text, error_message_text, InputHints.ignoring_input)
await turn_context.send_activity(error_message)
# Send a trace activity, which will be displayed in Bot Framework Emulator.
await turn_context.send_trace_activity(
label="TurnError",
name="on_turn_error Trace",
value=f"{error}",
value_type="https://www.botframework.com/schemas/error",
)
except Exception as exception:
print(
f"\n Exception caught on _send_error_message : {exception}",
file=sys.stderr,
)
traceback.print_exc()
async def _send_eoc_to_parent(self, turn_context: TurnContext, error: Exception):
try:
# Send an EndOfConversation activity to the skill caller with the error to end the conversation,
# and let the caller decide what to do.
end_of_conversation = Activity(type=ActivityTypes.end_of_conversation)
end_of_conversation.code = "SkillError"
end_of_conversation.text = str(error)
await turn_context.send_activity(end_of_conversation)
except Exception as exception:
print(
f"\n Exception caught on _send_eoc_to_parent : {exception}",
file=sys.stderr,
)
traceback.print_exc()
@@ -0,0 +1,55 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import os
from aiohttp import web
from aiohttp.web import Request, Response
from bot import bot
from config import config
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Endpoint for processing messages
async def messages(req: Request):
"""
Endpoint for processing messages with the Skill Bot.
"""
logger.info("Received a message.")
body = await req.json()
logger.info("Request body: %s", body)
# Process the incoming request
# NOTE in the context of Skills, we MUST return the response to the Copilot Studio as the response to the request
# In other channel (ex. Teams), this would not be required, and activities would be sent to the Bot Framework
return await bot.process(req)
async def copilot_manifest(req: Request):
# load manifest from file and interpolate with env vars
with open("copilot-studio.manifest.json") as f:
manifest = f.read()
# Get container app current ingress fqdn
# See https://learn.microsoft.com/en-us/azure/container-apps/environment-variables?tabs=portal
fqdn = f"https://{os.getenv('CONTAINER_APP_NAME')}.{os.getenv('CONTAINER_APP_ENV_DNS_SUFFIX')}/api/messages"
manifest = manifest.replace("__botEndpoint", fqdn).replace("__botAppId", config.APP_ID)
return Response(
text=manifest,
content_type="application/json",
)
APP = web.Application()
APP.router.add_post("/api/messages", messages)
APP.router.add_get("/manifest", copilot_manifest)
if __name__ == "__main__":
try:
web.run_app(APP, host=config.HOST, port=config.PORT)
except Exception as error:
raise error
@@ -0,0 +1,41 @@
# Copyright (c) Microsoft. All rights reserved.
# See https://github.com/microsoft/BotBuilder-Samples/blob/main/samples/python/80.skills-simple-bot-to-bot/echo-skill-bot/authentication/allowed_callers_claims_validator.py
from collections.abc import Awaitable, Callable
from botframework.connector.auth import JwtTokenValidation, SkillValidation
from config import Config
class AllowedCallersClaimsValidator:
config_key = "ALLOWED_CALLERS"
def __init__(self, config: Config):
if not config:
raise TypeError("AllowedCallersClaimsValidator: config object cannot be None.")
# ALLOWED_CALLERS is the setting in config.py file
# that consists of the list of parent bot ids that are allowed to access the skill
# to add a new parent bot simply go to the AllowedCallers and add
# the parent bot's microsoft app id to the list
caller_list = getattr(config, self.config_key)
if caller_list is None:
raise TypeError(f'"{self.config_key}" not found in configuration.')
self._allowed_callers = frozenset(caller_list)
@property
def claims_validator(self) -> Callable[[list[dict]], Awaitable]:
async def allow_callers_claims_validator(claims: dict[str, object]):
# if allowed_callers is None we allow all calls
if "*" not in self._allowed_callers and SkillValidation.is_skill_claim(claims):
# Check that the appId claim in the skill request is in the list of skills configured for this bot.
app_id = JwtTokenValidation.get_app_id_from_claims(claims)
if app_id not in self._allowed_callers:
raise PermissionError(
f'Received a request from a bot with an app ID of "{app_id}".'
f" To enable requests from this caller, add the app ID to your configuration file."
)
return
return allow_callers_claims_validator
@@ -0,0 +1,79 @@
# Copyright (c) Microsoft. All rights reserved.
from adapter import AdapterWithErrorHandler
# Custom classes to handle errors and claims validation
from auth import AllowedCallersClaimsValidator
from botbuilder.core import MemoryStorage, MessageFactory, TurnContext
from botbuilder.integration.aiohttp import ConfigurationBotFrameworkAuthentication
from botbuilder.schema import (
Activity,
EndOfConversationCodes,
InputHints,
)
from botframework.connector.auth import AuthenticationConfiguration
from config import config
# This is the SK agent that will be used to handle the conversation
from sk_conversation_agent import agent
from teams import Application, ApplicationOptions
from teams.state import TurnState
from semantic_kernel.contents import ChatHistory
# This is required for bot to work as Copilot Skill,
# not adding a claims validator will result in an error
claims_validator = AllowedCallersClaimsValidator(config)
auth = AuthenticationConfiguration(tenant_id=config.APP_TENANTID, claims_validator=claims_validator.claims_validator)
# Create the bot application
# We use the Teams Application class to create the bot application,
# then we added a custom adapter for skill errors handling.
bot = Application[TurnState](
ApplicationOptions(
bot_app_id=config.APP_ID,
storage=MemoryStorage(),
# CANNOT PASS A DICT HERE; MUST PASS A CLASS WITH APP_ID, APP_PASSWORD, AND APP_TENANTID ATTRIBUTES
adapter=AdapterWithErrorHandler(ConfigurationBotFrameworkAuthentication(config, auth_configuration=auth)),
)
)
@bot.before_turn
async def setup_chathistory(context: TurnContext, state: TurnState):
chat_history = state.conversation.get("chat_history") or ChatHistory()
state.conversation["chat_history"] = chat_history
return state
@bot.activity("message")
async def on_message(context: TurnContext, state: TurnState):
user_message = context.activity.text
# Get the chat_history from the conversation state
chat_history: ChatHistory = state.conversation.get("chat_history")
# Add the new user message
chat_history.add_user_message(user_message)
# Get the response from the semantic kernel agent (v1.22.0 and later)
sk_response = await agent.get_response(history=chat_history, user_input=user_message)
# Store the updated chat_history back into conversation state
state.conversation["chat_history"] = chat_history
# Send the response back to the user
# NOTE in the context of a Copilot Skill,
# the response is sent as a Response from /api/messages endpoint
await context.send_activity(MessageFactory.text(sk_response, input_hint=InputHints.ignoring_input))
# Skills must send an EndOfConversation activity to indicate the conversation is complete
# NOTE: this is a simple example, in a real skill you would likely want to send this
# only when the user has completed their task
end = Activity.create_end_of_conversation_activity()
end.code = EndOfConversationCodes.completed_successfully
await context.send_activity(end)
return True
@@ -0,0 +1,45 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from dotenv import load_dotenv
load_dotenv(override=True)
class Config:
"""Bot Configuration"""
HOST = os.getenv("HOST", "localhost")
PORT = int(os.getenv("PORT", 8080))
# DO NOT CHANGE THIS KEYS!!
# These keys are used to validate the bot's identity
# and must match these named as Bot configuration expects
APP_ID = os.getenv("BOT_APP_ID")
APP_PASSWORD = os.getenv("BOT_PASSWORD")
APP_TENANTID = os.getenv("BOT_TENANT_ID")
APP_TYPE = os.getenv("APP_TYPE", "singletenant")
# Required for Copilot Skill
# Can be a list of allowed agent Ids,
# or "*" to allow any agent
ALLOWED_CALLERS = os.getenv("ALLOWED_CALLERS", ["*"])
# Required for Azure OpenAI
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME")
AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT")
AZURE_OPENAI_API_VERSION = os.getenv("AZURE_OPENAI_API_VERSION")
def validate(self):
if not self.HOST or not self.PORT:
raise Exception("Missing required configuration. HOST and PORT must be set.")
if not self.APP_ID or not self.APP_PASSWORD or not self.APP_TENANTID:
raise Exception("Missing required configuration. APP_ID, APP_PASSWORD, and APP_TENANT_ID must be set.")
if not self.ALLOWED_CALLERS:
raise Exception("Missing required configuration. ALLOWED_CALLERS must be set.")
config = Config()
config.validate()
@@ -0,0 +1,25 @@
{
"$schema": "https://schemas.botframework.com/schemas/skills/v2.2/skill-manifest.json",
"$id": "SKCopilotSkill",
"name": "SK Copilot Skill",
"version": "1.0",
"description": "This is a sample skill using Semantic Kernel",
"publisherName": "Microsoft",
"privacyUrl": "https://www.microsoft.com/en-us/privacy/privacystatement",
"iconUrl": "https://docs.botframework.com/static/devportal/client/images/bot-framework-default.png",
"endpoints": [
{
"name": "default",
"protocol": "BotFrameworkV3",
"description": "Default endpoint for the bot",
"endpointUrl": "__botEndpoint",
"msAppId": "__botAppId"
}
],
"activities": {
"message": {
"type": "message",
"description": "Invoke Semantic Kernel skill"
}
}
}
@@ -0,0 +1,23 @@
FROM python:3.12-slim
# Step 1 - Install dependencies
WORKDIR /app
# Step 2 - Copy only requirements.txt
COPY requirements.txt /app
# Step 4 - Install pip dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Step 5 - Copy the rest of the files
COPY . .
ENV PYTHONUNBUFFERED=1
# Expose the application port
EXPOSE 80
ENV HOST 0.0.0.0
ENV PORT 80
# do not change the arguments
CMD ["python", "app.py"]
@@ -0,0 +1,4 @@
python-dotenv>=1.0.1
botbuilder-integration-aiohttp>=4.15.0
teams-ai>=1.4.0,<2.0.0
semantic-kernel>=1.22.0
@@ -0,0 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.
from azure.identity import AzureCliCredential
from semantic_kernel.agents import ChatCompletionAgent
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
agent = ChatCompletionAgent(
service=AzureChatCompletion(credential=AzureCliCredential()),
name="ChatAgent",
instructions="You invent jokes to have a fun conversation with the user.",
)
@@ -0,0 +1,58 @@
### Understanding Semantic Kernel AI Connectors
AI Connectors in Semantic Kernel are components that facilitate communication between the Kernel's core functionalities and various AI services. They abstract the intricate details of service-specific protocols, allowing developers to seamlessly interact with AI services for tasks like text generation, chat interactions, and more.
### Using AI Connectors in Semantic Kernel
Developers utilize AI connectors to connect their applications to different AI services efficiently. The connectors manage the requests and responses, providing a streamlined way to leverage the power of these AI services without needing to handle the specific communication protocols each service requires.
### Creating Custom AI Connectors in Semantic Kernel
To create a custom AI connector in Semantic Kernel, one must extend the base classes provided, such as `ChatCompletionClientBase` and `AIServiceClientBase`. Below is a guide and example for implementing a mock AI connector:
#### Step-by-Step Walkthrough
1. **Understand the Base Classes**: The foundational classes `ChatCompletionClientBase` and `AIServiceClientBase` provide necessary methods and structures for creating chat-based AI connectors.
2. **Implementing the Connector**: Here's a mock implementation example illustrating how to implement a connector without real service dependencies, ensuring compatibility with Pydantic's expectations within the framework:
```python
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
class MockAIChatCompletionService(ChatCompletionClientBase):
def __init__(self, ai_model_id: str):
super().__init__(ai_model_id=ai_model_id)
async def _inner_get_chat_message_contents(self, chat_history, settings):
# Mock implementation: returns dummy chat message content for demonstration.
return [{"role": "assistant", "content": "Mock response based on your history."}]
def service_url(self):
return "http://mock-ai-service.com"
```
### Usage Example
The following example demonstrates how to integrate and use the `MockAIChatCompletionService` in an application:
```python
import asyncio
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
async def main():
chat_history = ChatHistory(messages=[{"role": "user", "content": "Hello"}])
settings = PromptExecutionSettings(model="mock-model")
service = MockAIChatCompletionService(ai_model_id="mock-model")
response = await service.get_chat_message_contents(chat_history, settings)
print(response)
# Run the main function
asyncio.run(main())
```
### Conclusion
By following the revised guide and understanding the base class functionalities, developers can effectively create custom connectors within Semantic Kernel. This structured approach enhances integration with various AI services while ensuring alignment with the framework's architectural expectations. Custom connectors offer flexibility, allowing developers to adjust implementations to meet specific service needs, such as additional logging, authentication, or modifications tailored to specific protocols. This guide provides a strong foundation upon which more complex and service-specific extensions can be built, promoting robust and scalable AI service integration.
@@ -0,0 +1,117 @@
# Document Generator
This sample app demonstrates how to create technical documents for a codebase using AI. More specifically, it uses the agent framework offered by **Semantic Kernel** to ochestrate multiple agents to create a technical document.
This sample app also provides telemetry to monitor the agents, making it easier to observe the inner workings of the agents.
To learn more about agents, please refer to this introduction [video](https://learn.microsoft.com/en-us/shows/generative-ai-for-beginners/ai-agents-generative-ai-for-beginners).
To learn more about the Semantic Kernel Agent Framework, please refer to the [Semantic Kernel documentation](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-architecture?pivots=programming-language-python).
> Note: This sample app cannot guarantee to generate a perfect technical document each time due to the stochastic nature of the AI model. Please a version of the document generated by the app in [GENERATED_DOCUMENT.md](GENERATED_DOCUMENT.md).
## Design
### Tools/Plugins
- **Code Execution Plugin**: This plugin offers a sandbox environment to execute Python snippets. It returns the output of the program or errors if any.
- **Repository File Plugin**: This plugin allows the AI to retrieve files from the Semantic Kernel repository.
- **User Input Plugin**: This plugin allows the AI to present content to the user and receive feedback.
### Agents
- **Content Creation Agent**: This agent is responsible for creating the content of the document. This agent has access to the **Repository File Plugin** to read source files it deems necessary for reference.
- **Code Validation Agent**: This agent is responsible for validating the code snippets in the document. This agent has access to the **Code Execution Plugin** to execute the code snippets.
- **User Agent**: This agent is responsible for interacting with the user. This agent has access to the **User Input Plugin** to present content to the user and receive feedback.
### Agent Selection Strategy
### Termination Strategy
## Prerequisites
1. Azure OpenAI
2. Azure Application Insights
## Additional packages
- `AICodeSandbox` - for executing AI generated code in a sandbox environment
```bash
pip install ai-code-sandbox
```
> You must also have `docker` installed and running on your machine. Follow the instructions [here](https://docs.docker.com/get-started/introduction/get-docker-desktop/) to install docker for your platform. Images will be pulled during runtime if not already present. Containers will be created and destroyed during code execution.
## Running the app
### Step 1: Set up the environment
The Document Generator demo currently supports two different AI Services. If integrating into your own code, other AI services can be used. See Semantic Kernel's Learn Site [page](https://learn.microsoft.com/en-us/semantic-kernel/concepts/ai-services/chat-completion/?tabs=csharp-AzureOpenAI%2Cpython-AzureOpenAI%2Cjava-AzureOpenAI&pivots=programming-language-python) about other available Chat Completion services.
#### OpenAI
Make sure you have the following environment variables set:
```bash
OPENAI_CHAT_MODEL_ID=<model-id>
OPENAI_API_KEY=<your-key>
```
> gpt-4o-2024-08-06 was used to generate [GENERATED_DOCUMENT.md](GENERATED_DOCUMENT.md).
> Feel free to use other models from OpenAI or other providers. When you use models from another provider, make sure to update the chat completion services accordingly.
#### Azure OpenAI
```bash
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=<deployment-name>
AZURE_OPENAI_ENDPOINT=<endpoint> # in the form of `https://<resource>.openai.azure.com/`
AZURE_OPENAI_API_KEY=<api-key> # only required if using api key auth
AZURE_OPENAI_API_VERSION=<api-version> # optional, defaults to the latest Azure OpenAI GA API version of `2024-10-21` if not provided
```
```bash
### Step 2: Run the app
```bash
python ./main.py
```
Expected output:
```bash
==== ContentCreationAgent just responded ====
==== CodeValidationAgent just responded ====
==== ContentCreationAgent just responded ====
...
```
## Customization
Since this is a sample app that demonstrates the creation of a technical document on Semantic Kernel AI connectors, you can customize the app to suit your needs. You can try different tasks, add more agents, tune existing agents, change the agent selection strategy, or modify the termination strategy.
- To try a different task, modify the `TASK` prompt in `main.py`.
- To add more agents, create a new agent under `agents/` and add it to the `agents` list in `main.py`.
- To tune existing agents, modify the `INSTRUCTION` prompt in the agent's source code.
- To change the agent selection strategy, modify `custom_selection_strategy.py`.
- To change the termination strategy, modify `custom_termination_strategy.py`.
## Optional: Monitoring the agents
When you see the final document generated by the app, what you see is actually the creation of multiple agents working together. You may wonder, how did the agents work together to create the document? What was the sequence of actions taken by the agents? How did the agents interact with each other? To answer these questions, you need to **observe** the agents.
Semantic Kernel by default instruments all the LLM calls. However, for agents there is no default instrumentation. This sample app shows how one can extend the Semantic Kernel agent to add instrumentation.
> There are currently no standards on what information needs to be captured for agents as the concept of agents is still relatively new. At the time of writing, the Semantic Convention for agents is still in the draft stage: <https://github.com/open-telemetry/semantic-conventions/issues/1732>
To monitor the agents, set the following environment variables:
```env
AZURE_APP_INSIGHTS_CONNECTION_STRING=<your-connection-string>
SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS=true
SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE=true
```
Follow this guide to inspect the telemetry data: <https://learn.microsoft.com/en-us/semantic-kernel/concepts/enterprise-readiness/observability/telemetry-with-app-insights?tabs=Powershell&pivots=programming-language-python#inspect-telemetry-data>
Or follow this guide to visualize the telemetry data on Azure AI Foundry: <https://learn.microsoft.com/en-us/semantic-kernel/concepts/enterprise-readiness/observability/telemetry-with-azure-ai-foundry-tracing#visualize-traces-on-azure-ai-foundry-tracing-ui-1>
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable, Awaitable, Callable
from typing import TYPE_CHECKING, Any
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from samples.demos.document_generator.agents.custom_agent_base import CustomAgentBase, Services
from samples.demos.document_generator.plugins.code_execution_plugin import CodeExecutionPlugin
from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.functions import KernelArguments
if TYPE_CHECKING:
from semantic_kernel.agents.agent import AgentResponseItem, AgentThread
from semantic_kernel.kernel import Kernel
INSTRUCTION = """
You are a code validation agent in a collaborative document creation chat.
Your task is to validate Python code in the latest document draft and summarize any errors.
Follow the instructions in the document to assemble the code snippets into a single Python script.
If the snippets in the document are from multiple scripts, you need to modify them to work together as a single script.
Execute the code to validate it. If there are errors, summarize the error messages.
Do not try to fix the errors.
"""
DESCRIPTION = """
Select me to validate the Python code in the latest document draft.
"""
class CodeValidationAgent(CustomAgentBase):
def __init__(self):
super().__init__(
service=self._create_ai_service(Services.AZURE_OPENAI),
plugins=[CodeExecutionPlugin()],
name="CodeValidationAgent",
instructions=INSTRUCTION.strip(),
description=DESCRIPTION.strip(),
)
@override
async def invoke(
self,
*,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
thread: "AgentThread | None" = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable["AgentResponseItem[ChatMessageContent]"]:
async for response in super().invoke(
messages=messages,
thread=thread,
on_intermediate_message=on_intermediate_message,
arguments=arguments,
kernel=kernel,
additional_user_message="Now validate the Python code in the latest document draft and summarize any errors.", # noqa: E501
**kwargs,
):
yield response
@@ -0,0 +1,64 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable, Awaitable, Callable
from typing import TYPE_CHECKING, Any
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from samples.demos.document_generator.agents.custom_agent_base import CustomAgentBase, Services
from samples.demos.document_generator.plugins.repo_file_plugin import RepoFilePlugin
from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.functions import KernelArguments
if TYPE_CHECKING:
from semantic_kernel.agents import AgentResponseItem, AgentThread
from semantic_kernel.kernel import Kernel
INSTRUCTION = """
You are part of a chat with multiple agents focused on creating technical content.
Your task is to generate informative and engaging technical content,
including code snippets to explain concepts or demonstrate features.
Incorporate feedback by providing the updated full content with changes.
"""
DESCRIPTION = """
Select me to generate new content or to revise existing content.
"""
class ContentCreationAgent(CustomAgentBase):
def __init__(self):
super().__init__(
service=self._create_ai_service(Services.AZURE_OPENAI),
plugins=[RepoFilePlugin()],
name="ContentCreationAgent",
instructions=INSTRUCTION.strip(),
description=DESCRIPTION.strip(),
)
@override
async def invoke(
self,
*,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
thread: "AgentThread | None" = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable["AgentResponseItem[ChatMessageContent]"]:
async for response in super().invoke(
messages=messages,
thread=thread,
on_intermediate_message=on_intermediate_message,
arguments=arguments,
kernel=kernel,
additional_user_message="Now generate new content or revise existing content to incorporate feedback.",
**kwargs,
):
yield response
@@ -0,0 +1,122 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from abc import ABC
from collections.abc import AsyncIterable, Awaitable, Callable
from enum import Enum
from typing import TYPE_CHECKING, Any, Literal
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.contents.utils.author_role import AuthorRole
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from semantic_kernel.agents import ChatCompletionAgent
from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.functions import KernelArguments
from semantic_kernel.kernel import Kernel
if TYPE_CHECKING:
from semantic_kernel.agents import AgentResponseItem, AgentThread
class Services(str, Enum):
"""Enum for supported chat completion services.
For service specific settings, refer to this documentation:
https://learn.microsoft.com/en-us/semantic-kernel/concepts/ai-services/chat-completion
"""
OPENAI = "openai"
AZURE_OPENAI = "azure_openai"
class CustomAgentBase(ChatCompletionAgent, ABC):
def _create_ai_service(
self, service: Services = Services.AZURE_OPENAI, instruction_role: Literal["system", "developer"] = "system"
) -> ChatCompletionClientBase:
"""Create an AI service for the agent.
Note: if using Azure OpenAI, ensure the following environment variables are present in your .env file:
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME
- AZURE_OPENAI_ENDPOINT
- AZURE_OPENAI_API_KEY (if using Azure OpenAI API key authentication)
- AZURE_OPENAI_API_VERSION
If using OpenAI, ensure the following environment variables are present in your .env file:
- OPENAI_API_KEY
- OPENAI_CHAT_MODEL_ID
Args:
service (Services): The AI service to use.
instruction_role (str): The role of the instruction in the chat completion request.
Can be either "system" or "developer". Defaults to "system".
Returns:
ChatCompletionClientBase: The AI service instance.
"""
match service:
case Services.AZURE_OPENAI:
from azure.identity import AzureCliCredential
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
return AzureChatCompletion(instruction_role=instruction_role, credential=AzureCliCredential())
case Services.OPENAI:
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
return OpenAIChatCompletion(instruction_role=instruction_role)
case _:
raise ValueError(
f"Unsupported service: {service}. Supported services are: {', '.join([s.value for s in Services])}"
)
@override
async def invoke(
self,
*,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
thread: "AgentThread | None" = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
additional_user_message: str | None = None,
**kwargs: Any,
) -> AsyncIterable["AgentResponseItem[ChatMessageContent]"]:
normalized_messages = self._normalize_messages(messages)
if additional_user_message:
normalized_messages.append(ChatMessageContent(role=AuthorRole.USER, content=additional_user_message))
# Filter out empty or function-only messages to avoid polluting context
messages_to_pass = [m for m in normalized_messages if m.content]
async for response in super().invoke(
messages=messages_to_pass, # type: ignore
thread=thread,
on_intermediate_message=on_intermediate_message,
arguments=arguments,
kernel=kernel,
**kwargs,
):
yield response
def _normalize_messages(
self, messages: str | ChatMessageContent | list[str | ChatMessageContent] | None
) -> list[ChatMessageContent]:
if messages is None:
return []
if isinstance(messages, (str, ChatMessageContent)):
messages = [messages]
normalized: list[ChatMessageContent] = []
for msg in messages:
if isinstance(msg, str):
normalized.append(ChatMessageContent(role=AuthorRole.USER, content=msg))
else:
normalized.append(msg)
return normalized
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable, Awaitable, Callable
from typing import TYPE_CHECKING, Any
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from samples.demos.document_generator.agents.custom_agent_base import CustomAgentBase, Services
from samples.demos.document_generator.plugins.user_plugin import UserPlugin
from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.functions import KernelArguments
if TYPE_CHECKING:
from semantic_kernel.agents.agent import AgentResponseItem, AgentThread
from semantic_kernel.kernel import Kernel
INSTRUCTION = """
You are part of a chat with multiple agents working on a document.
Your task is to summarize the user's feedback on the latest draft from the author agent.
Present the draft to the user and summarize their feedback.
Do not try to address the user's feedback in this chat.
"""
DESCRIPTION = """
Select me if you want to ask the user to review the latest draft for publication.
"""
class UserAgent(CustomAgentBase):
def __init__(self):
super().__init__(
service=self._create_ai_service(Services.AZURE_OPENAI),
plugins=[UserPlugin()],
name="UserAgent",
instructions=INSTRUCTION.strip(),
description=DESCRIPTION.strip(),
)
@override
async def invoke(
self,
*,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
thread: "AgentThread | None" = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable["AgentResponseItem[ChatMessageContent]"]:
async for response in super().invoke(
messages=messages,
thread=thread,
on_intermediate_message=on_intermediate_message,
arguments=arguments,
kernel=kernel,
additional_user_message="Now present the latest draft to the user for feedback and summarize their feedback.", # noqa: E501
**kwargs,
):
yield response
@@ -0,0 +1,100 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING, ClassVar
from opentelemetry import trace
from pydantic import Field
from semantic_kernel.agents.strategies.selection.selection_strategy import SelectionStrategy
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.connectors.ai.open_ai import (
AzureChatPromptExecutionSettings,
OpenAIChatCompletion,
)
from semantic_kernel.contents import ChatHistory
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.agents import Agent
from semantic_kernel.contents.chat_message_content import ChatMessageContent
NEWLINE = "\n"
@experimental
class CustomSelectionStrategy(SelectionStrategy):
"""A selection strategy that selects the next agent intelligently."""
NUM_OF_RETRIES: ClassVar[int] = 3
chat_completion_service: ChatCompletionClientBase = Field(default_factory=lambda: OpenAIChatCompletion())
async def next(self, agents: list["Agent"], history: list["ChatMessageContent"]) -> "Agent":
"""Select the next agent to interact with.
Args:
agents: The list of agents to select from.
history: The history of messages in the conversation.
Returns:
The next agent to interact with.
"""
if len(agents) == 0:
raise ValueError("No agents to select from")
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("selection_strategy"):
chat_history = ChatHistory(system_message=self.get_system_message(agents).strip())
for message in history:
content = message.content
# We don't want to add messages whose text content is empty.
# Those messages are likely messages from function calls and function results.
if content:
chat_history.add_message(message)
chat_history.add_user_message("Now follow the rules and select the next agent by typing the agent's index.")
for _ in range(self.NUM_OF_RETRIES):
completion = await self.chat_completion_service.get_chat_message_content(
chat_history,
AzureChatPromptExecutionSettings(),
)
if completion is None:
continue
try:
return agents[int(completion.content)]
except ValueError as ex:
chat_history.add_message(completion)
chat_history.add_user_message(str(ex))
chat_history.add_user_message(f"You must only say a number between 0 and {len(agents) - 1}.")
raise ValueError("Failed to select an agent since the model did not return a valid index")
def get_system_message(self, agents: list["Agent"]) -> str:
return f"""
You are in a multi-agent chat to create a document.
Each message in the chat history contains the agent's name and the message content.
Initially, the chat history may be empty.
Here are the agents with their indices, names, and descriptions:
{NEWLINE.join(f"[{index}] {agent.name}:{NEWLINE}{agent.description}" for index, agent in enumerate(agents))}
Your task is to select the next agent based on the conversation history.
The conversation must follow these steps:
1. The content creation agent writes a draft.
2. The code validation agent checks the code in the draft.
3. The content creation agent updates the draft based on the feedback.
4. The code validation agent checks the updated code.
...
If the code validation agent approves the code, the user agent can ask the user for final feedback.
N: The user agent provides feedback.
(If the feedback is not positive, the conversation goes back to the content creation agent.)
Respond with a single number between 0 and {len(agents) - 1}, representing the agent's index.
Only return the index as an integer.
"""
@@ -0,0 +1,91 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING, ClassVar
from opentelemetry import trace
from pydantic import Field
from semantic_kernel.agents.strategies import TerminationStrategy
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.connectors.ai.open_ai import (
AzureChatPromptExecutionSettings,
OpenAIChatCompletion,
)
from semantic_kernel.contents import ChatHistory
if TYPE_CHECKING:
from semantic_kernel.agents.agent import Agent
from semantic_kernel.contents.chat_message_content import ChatMessageContent
TERMINATE_TRUE_KEYWORD = "yes"
TERMINATE_FALSE_KEYWORD = "no"
NEWLINE = "\n"
class CustomTerminationStrategy(TerminationStrategy):
NUM_OF_RETRIES: ClassVar[int] = 3
maximum_iterations: int = 20
chat_completion_service: ChatCompletionClientBase = Field(default_factory=lambda: OpenAIChatCompletion())
async def should_agent_terminate(self, agent: "Agent", history: list["ChatMessageContent"]) -> bool:
"""Check if the agent should terminate.
Args:
agent: The agent to check.
history: The history of messages in the conversation.
"""
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("terminate_strategy"):
chat_history = ChatHistory(system_message=self.get_system_message().strip())
for message in history:
content = message.content
# We don't want to add messages whose text content is empty.
# Those messages are likely messages from function calls and function results.
if content:
chat_history.add_message(message)
chat_history.add_user_message(
"Is the latest content approved by all agents? "
f"Answer with '{TERMINATE_TRUE_KEYWORD}' or '{TERMINATE_FALSE_KEYWORD}'."
)
for _ in range(self.NUM_OF_RETRIES):
completion = await self.chat_completion_service.get_chat_message_content(
chat_history,
AzureChatPromptExecutionSettings(),
)
if not completion:
continue
if TERMINATE_FALSE_KEYWORD in completion.content.lower():
return False
if TERMINATE_TRUE_KEYWORD in completion.content.lower():
return True
chat_history.add_message(completion)
chat_history.add_user_message(
f"You must only say either '{TERMINATE_TRUE_KEYWORD}' or '{TERMINATE_FALSE_KEYWORD}'."
)
raise ValueError(
"Failed to determine if the agent should terminate because the model did not return a valid response."
)
def get_system_message(self) -> str:
return f"""
You are in a chat with multiple agents collaborating to create a document.
Each message in the chat history contains the agent's name and the message content.
The chat history may start empty as no agents have spoken yet.
Here are the agents with their indices, names, and descriptions:
{NEWLINE.join(f"[{index}] {agent.name}:{NEWLINE}{agent.description}" for index, agent in enumerate(self.agents))}
Your task is NOT to continue the conversation. Determine if the latest content is approved by all agents.
If approved, say "{TERMINATE_TRUE_KEYWORD}". Otherwise, say "{TERMINATE_FALSE_KEYWORD}".
"""
@@ -0,0 +1,138 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import os
from dotenv import load_dotenv
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
from samples.demos.document_generator.agents.code_validation_agent import CodeValidationAgent
from samples.demos.document_generator.agents.content_creation_agent import ContentCreationAgent
from samples.demos.document_generator.agents.user_agent import UserAgent
from samples.demos.document_generator.custom_selection_strategy import CustomSelectionStrategy
from samples.demos.document_generator.custom_termination_strategy import CustomTerminationStrategy
from semantic_kernel.agents import AgentGroupChat
from semantic_kernel.contents import AuthorRole, ChatMessageContent
"""
Note: This sample use the `AgentGroupChat` feature of Semantic Kernel, which is
no longer maintained. For a replacement, consider using the `GroupChatOrchestration`.
Read more about the `GroupChatOrchestration` here:
https://learn.microsoft.com/semantic-kernel/frameworks/agent/agent-orchestration/group-chat?pivots=programming-language-python
Here is a migration guide from `AgentGroupChat` to `GroupChatOrchestration`:
https://learn.microsoft.com/semantic-kernel/support/migration/group-chat-orchestration-migration-guide?pivots=programming-language-python
"""
TASK = """
Create a blog post to share technical details about the Semantic Kernel AI connectors.
The content of the blog post should include the following:
1. What are AI connectors in Semantic Kernel?
2. How do people use AI connectors in Semantic Kernel?
3. How do devs create custom AI connectors in Semantic Kernel?
- Include a walk through of creating a custom AI connector.
The connector may not connect to a real service, but should demonstrate the process.
- Include a sample on how to use the connector.
- If a reader follows the walk through and the sample, they should be able to run the connector.
Here is the file that contains the source code for the base class of the AI connectors:
semantic_kernel/connectors/ai/chat_completion_client_base.py
semantic_kernel/services/ai_service_client_base.py
Here are some files containing the source code that may be useful:
semantic_kernel/connectors/ai/ollama/services/ollama_chat_completion.py
semantic_kernel/connectors/ai/open_ai/services/open_ai_chat_completion_base.py
semantic_kernel/contents/chat_history.py
If you want to reference the implementations of other AI connectors, you can find them under the following directory:
semantic_kernel/connectors/ai
"""
load_dotenv()
AZURE_APP_INSIGHTS_CONNECTION_STRING = os.getenv("AZURE_APP_INSIGHTS_CONNECTION_STRING")
resource = Resource.create({ResourceAttributes.SERVICE_NAME: "Document Generator"})
def set_up_tracing():
from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import set_tracer_provider
# Initialize a trace provider for the application. This is a factory for creating tracers.
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(AzureMonitorTraceExporter(connection_string=AZURE_APP_INSIGHTS_CONNECTION_STRING))
)
# Sets the global default tracer provider
set_tracer_provider(tracer_provider)
def set_up_logging():
from azure.monitor.opentelemetry.exporter import AzureMonitorLogExporter
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
# Create and set a global logger provider for the application.
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(AzureMonitorLogExporter(connection_string=AZURE_APP_INSIGHTS_CONNECTION_STRING))
)
# Sets the global default logger provider
set_logger_provider(logger_provider)
# Create a logging handler to write logging records, in OTLP format, to the exporter.
handler = LoggingHandler()
# Attach the handler to the root logger. `getLogger()` with no arguments returns the root logger.
# Events from all child loggers will be processed by this handler.
logger = logging.getLogger()
logger.addHandler(handler)
logger.setLevel(logging.INFO)
async def main():
if AZURE_APP_INSIGHTS_CONNECTION_STRING:
set_up_tracing()
set_up_logging()
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("main"):
agents = [
ContentCreationAgent(),
UserAgent(),
CodeValidationAgent(),
]
group_chat = AgentGroupChat(
agents=agents,
termination_strategy=CustomTerminationStrategy(agents=agents),
selection_strategy=CustomSelectionStrategy(),
)
await group_chat.add_chat_message(
ChatMessageContent(
role=AuthorRole.USER,
content=TASK.strip(),
)
)
async for response in group_chat.invoke():
print(f"==== {response.name} just responded ====")
# print(response.content)
content_history: list[ChatMessageContent] = []
async for message in group_chat.get_chat_messages(agent=agents[0]):
if message.name == agents[0].name:
# The chat history contains responses from other agents.
content_history.append(message)
# The chat history is in descending order.
print("Final content:")
print(content_history[0].content)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,26 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Annotated
from ai_code_sandbox import AICodeSandbox
from semantic_kernel.functions import kernel_function
class CodeExecutionPlugin:
"""A plugin that runs Python code snippets."""
@kernel_function(description="Run a Python code snippet. You can assume all the necessary packages are installed.")
def run(
self, code: Annotated[str, "The Python code snippet."]
) -> Annotated[str, "Returns the output of the code."]:
"""Run a Python code snippet."""
sandbox: AICodeSandbox = AICodeSandbox(
custom_image="python:3.12-slim",
packages=["semantic_kernel"],
)
try:
return sandbox.run_code(code)
finally:
sandbox.close()
@@ -0,0 +1,51 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Annotated
from semantic_kernel.functions import kernel_function
class RepoFilePlugin:
"""A plugin that reads files from this repository.
This plugin assumes that the code is run within the Semantic Kernel repository.
"""
@kernel_function(description="Read a file given a relative path to the root of the repository.")
def read_file_by_path(
self, path: Annotated[str, "The relative path to the file."]
) -> Annotated[str, "Returns the file content."]:
path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", path)
try:
with open(path) as file:
return file.read()
except FileNotFoundError:
raise FileNotFoundError(f"File {path} not found in repository.")
@kernel_function(
description="Read a file given the name of the file. Function will search for the file in the repository."
)
def read_file_by_name(
self, file_name: Annotated[str, "The name of the file."]
) -> Annotated[str, "Returns the file content."]:
path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
for root, dirs, files in os.walk(path):
if file_name in files:
print(f"Found file {file_name} in {root}.")
with open(os.path.join(root, file_name)) as file:
return file.read()
raise FileNotFoundError(f"File {file_name} not found in repository.")
@kernel_function(description="List all files or subdirectories in a directory.")
def list_directory(
self, path: Annotated[str, "Path of a directory relative to the root of the repository."]
) -> Annotated[str, "Returns a list of files and subdirectories as a string."]:
path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", path)
try:
files = os.listdir(path)
# Join the list of files into a single string
return "\n".join(files)
except FileNotFoundError:
raise FileNotFoundError(f"Directory {path} not found in repository.")
@@ -0,0 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Annotated
from semantic_kernel.functions import kernel_function
class UserPlugin:
"""A plugin that interacts with the user."""
@kernel_function(description="Present the content to user and request feedback.")
def request_user_feedback(
self, content: Annotated[str, "The content to present and request feedback on."]
) -> Annotated[str, "The feedback provided by the user."]:
"""Request user feedback on the content."""
return input(f"Please provide feedback on the content:\n\n{content}\n\n> ")
@@ -0,0 +1,61 @@
# Guided Conversations
This sample highlights a framework for a pattern of use cases we refer to as guided conversations.
These are scenarios where an agent with a goal and constraints leads a conversation. There are many of these scenarios where we hold conversations that are driven by an objective and constraints. For example:
- a teacher guiding a student through a lesson
- a call center representative collecting information about a customer's issue
- a sales representative helping a customer find the right product for their specific needs
- an interviewer asking candidate a series of questions to assess their fit for a role
- a nurse asking a series of questions to triage the severity of a patient's symptoms
- a meeting where participants go around sharing their updates and discussing next steps
The common thread between all these scenarios is that they are between a **creator** leading the conversation and a **user(s)** who are participating.
The creator defines the goals, a plan for how the conversation should flow, and often collects key information through a form throughout the conversation.
They must exercise judgment to navigate and adapt the conversation towards achieving the set goal all while writing down key information and planning in advance.
The goal of this framework is to show how we can build a common framework to create AI agents that can assist a creator in running conversational scenarios semi-autonomously and generating **artifacts** like notes, forms, and plans that can be used to track progress and outcomes. A key tenant of this framework is the following principal: *think with the model, plan with the code*. This means that the model is used to understand user inputs and make complex decisions, but code is used to apply constraints and provide structure to make the system **reliable**. To better understand this concept, start with the [notebooks](./notebooks/).
## Features
We were motivated to create this sample while noticing some common challenges with using agents for conversation scenarios:
| Common Challenges | Guided Conversations |
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Focus - Drift from their original goals | Define the agent's goal in terms of completing an ["artifact"](./guided_conversation/plugins/artifact.py), which is a precise representation of what the agent needs to do in the conversation |
| Pacing - Rushing through conversations, being overly verbose, and struggle to understand time | Encourage the agent to regularly update an [agenda](./guided_conversation/plugins/agenda.py) where each agenda item is allocated an estimated number of times, time limits are programmatically validated, and programmatically convert time-based units (e.g. seconds, minutes) to turns using [resource constraints](./guided_conversation/utils/resources.py) |
| Downstream Use Cases - Difficult to use chat logs for further processing or analysis | The [artifact](./guided_conversation/plugins/artifact.py) serves as (1) a structured record of the conversation that can be more easily analyzed afterward, (2) a way to monitor the agent's progress in real-time |
## Installation
This sample uses the same tooling as the [Semantic Kernel](https://github.com/microsoft/semantic-kernel/blob/main/python/pyproject.toml) Python source which uses [poetry](https://python-poetry.org/docs/) to install dependencies for development.
1. `poetry install`
1. Activate `.venv` that was created by poetry
1. Set up the environment variables or a `.env` file for the LLM service you want to use.
1. If you add new dependencies to the `pyproject.toml` file; run `poetry update`.
### Quickstart
1. Fork the repository.
1. Install dependencies (see Installation) & set up environment variables
1. Try the [01_guided_conversation_teaching.ipynb](./notebooks/01_guided_conversation_teaching.ipynb) as an example.
1. For best quality and reliability, we recommend using the `gpt-4-1106-preview` or `gpt-4o` models since this sample requires complex reasoning and function calling abilities.
## How You Can Use This Framework
### Add a new scenario
Create a new file and and define the following inputs:
- An artifact
- Rules
- Conversation flow (optional)
- Context (optional)
- Resource constraint (optional)
See the [interactive script](./interactive_guided_conversation.py) for an example.
### Editing Existing Plugins
Edit plugins at [plugins](./guided_conversation/plugins/)
### Editing the Orchestrator
Go to [guided_conversation_agent.py](./guided_conversation/plugins/guided_conversation_agent.py).
### Reusing Plugins
We also encourage the open source community to pull in the artifact and agenda plugins to accelerate existing work. We believe that these plugins alone can improve goal-following in other agents.
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,229 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from semantic_kernel import Kernel
from semantic_kernel.functions import FunctionResult, KernelArguments
from guided_conversation.plugins.agenda import Agenda
from guided_conversation.plugins.artifact import Artifact
from guided_conversation.utils.conversation_helpers import Conversation
from guided_conversation.utils.resources import GCResource, ResourceConstraintMode
logger = logging.getLogger(__name__)
conversation_plan_template = """<message role="system">You are a helpful, thoughtful, and meticulous assistant.
You are conducting a conversation with a user. \
Your goal is to complete an artifact as thoroughly as possible by the end of the conversation, and to ensure a smooth experience for the user.
This is the schema of the artifact you are completing:
{{ artifact_schema }}{{#if context}}
Here is some additional context about the conversation:
{{ context }}{{/if}}
Throughout the conversation, you must abide by these rules:
{{ rules }}{{#if current_state_description }}
Here's a description of the conversation flow:
{{ current_state_description }}
Follow this description, and exercise good judgment about when it is appropriate to deviate.{{/if}}
You will be provided the history of your conversation with the user up until now and the current state of the artifact.
Note that if the value for a field in the artifact is 'Unanswered', it means that the field has not been completed.
You need to select the best possible action(s), given the state of the conversation and the artifact.
These are the possible actions you can take:
{{#if show_agenda}}Update agenda (required parameters: items)
- If the latest agenda is set to "None", you should always pick this action.
- You should pick this action if you need to change your plan for the conversation to make the best use of the remaining turns available to you. \
Consider how long it usually takes to get the information you need (which is a function of the quality and pace of the user's responses), \
the number, complexity, and importance of the remaining fields in the artifact, and the number of turns remaining ({{ remaining_resource }}). \
Based on these factors, you might need to accelerate (e.g. combine several topics) or slow down the conversation (e.g. spread out a topic), in which case you should update the agenda accordingly. \
Note that skipping an artifact field is NOT a valid way to accelerate the conversation.
- You must provide an ordered list of items to be completed sequentially, where the first item contains everything you will do in the current turn of the conversation (in addition to updating the agenda). \
For example, if you choose to send a message to the user asking for their name and medical history, then you would write "ask for name and medical history" as the first item. \
If you think medical history will take longer than asking for the name, then you would write "complete medical history" as the second item, with an estimate of how many turns you think it will take. \
Do NOT include items that have already been completed. \
Items must always represent a conversation topic (corresponding to the "Send message to user" action). Updating the artifact (e.g. "update field X based on the discussion") or terminating the conversation is NOT a valid item.
- The latest agenda was created in the previous turn of the conversation. \
Even if the total turns in the latest agenda equals the remaining turns, you should still update the agenda if you think the current plan is suboptimal (e.g. the first item was completed, the order of items is not ideal, an item is too broad or not a conversation topic, etc.).
- Each item must have a description and and your best guess for the number of turns required to complete it. Do not provide a range of turns. \
It is EXTREMELY important that the total turns allocated across all items in the updated agenda (including the first item for the current turn) {{ total_resource_str }} \
Everything in the agenda should be something you expect to complete in the remaining turns - there shouldn't be any optional "buffer" items. \
It can be helpful to include the cumulative turns allocated for each item in the agenda to ensure you adhere to this rule, e.g. item 1 = 2 turns (cumulative total = 2), item 2 = 4 turns (cumulative total = 6), etc.
- Avoid high-level items like "ask follow-up questions" - be specific about what you need to do.
- Do NOT include wrap-up items such as "review and confirm all information with the user" (you should be doing this throughout the conversation) or "thank the user for their time". \
Do NOT repeat topics that have already been sufficiently addressed. {{ ample_time_str }}{{/if}}
Send message to user (required parameters: message)
- If there is no conversation history, you should always pick this action.
- You should pick this action if (a) the user asked a question or made a statement that you need to respond to, \
or (b) you need to follow-up with the user because the information they provided is incomplete, invalid, ambiguous, or in some way insufficient to complete the artifact. \
For example, if the artifact schema indicates that the "date of birth" field must be in the format "YYYY-MM-DD", but the user has only provided the month and year, you should send a message to the user asking for the day. \
Likewise, if the user claims that their date of birth is February 30, you should send a message to the user asking for a valid date. \
If the artifact schema is open-ended (e.g. it asks you to rate how pressing the user's issue is, without specifying rules for doing so), use your best judgment to determine whether you have enough information or you need to continue probing the user. \
It's important to be thorough, but also to avoid asking the user for unnecessary information.
Update artifact fields (required parameters: field, value)
- You should pick this action as soon as (a) the user provides new information that is not already reflected in the current state of the artifact and (b) you are able to submit a valid value for a field in the artifact using this new information. \
If you have already updated a field in the artifact and there is no new information to update the field with, you should not pick this action.
- Make sure the value adheres to the constraints of the field as specified in the artifact schema.
- If the user has provided all required information to complete a field (i.e. the criteria for "Send message to user" are not satisfied) but the information is in the wrong format, you should not ask the user to reformat their response. \
Instead, you should simply update the field with the correctly formatted value. For example, if the artifact asks for the date of birth in the format "YYYY-MM-DD", and the user provides their date of birth as "June 15, 2000", you should update the field with the value "2000-06-15".
- Prioritize accuracy over completion. You should never make up information or make assumptions in order to complete a field. \
For example, if the field asks for a 10-digit phone number, and the user provided a 9-digit phone number, you should not add a digit to the phone number in order to complete the field. \
Instead, you should follow-up with the user to ask for the correct phone number. If they still aren't able to provide one, you should leave the field unanswered.
- If the user isn't able to provide all of the information needed to complete a field, \
use your best judgment to determine if a partial answer is appropriate (assuming it adheres to the formatting requirements of the field). \
For example, if the field asks for a description of symptoms along with details about when the symptoms started, but the user isn't sure when their symptoms started, \
it's better to record the information they do have rather than to leave the field unanswered (and to indicate that the user was unsure about the start date).
- If it's possible to update multiple fields at once (assuming you're adhering to the above rules in all cases), you should do so. \
For example, if the user provides their full name and date of birth in the same message, you should select the "update artifact fields" action twice, once for each field.
End conversation (required parameters: None)
{{ termination_instructions }}
{{ resource_instructions }}
If you select the "Update artifact field" action or the "Update agenda" action, you should also select one of the "Send message to user" or "End conversation" actions. \
Note that artifact and updates updates will always be executed before a message is sent to the user or the conversation is terminated. \
Also note that only one message can be sent to the user at a time.
Your task is to state your step-by-step reasoning for the best possible action(s), followed by a final recommendation of which action(s) to take, including all required parameters.
Someone else will be responsible for executing the action(s) you select and they will only have access to your output \
(not any of the conversation history, artifact schema, or other context) so it is EXTREMELY important \
that you clearly specify the value of all required parameters for each action you select.</message>
<message role="user">Conversation history:
{{ chat_history }}
Latest agenda:
{{ agenda_state }}
Current state of the artifact:
{{ artifact_state }}</message>"""
async def conversation_plan_function(
kernel: Kernel,
chat_history: Conversation,
context: str,
rules: list[str],
conversation_flow: str,
current_artifact: Artifact,
req_settings: dict,
resource: GCResource,
agenda: Agenda,
) -> FunctionResult:
"""Reasons/plans about the next best action(s) to continue the conversation. In this function, a DESCRIPTION of the possible actions
are surfaced to the agent. Note that the agent will not execute the actions, but will provide a step-by-step reasoning for the best
possible action(s). The implication here is that NO tool/plugin calls are made, only a description of what tool calls might be called
is created.
Currently, the reasoning/plan from this function is passed to another function (which leverages openai tool calling) that will execute
the actions.
Args:
kernel (Kernel): The kernel object.
chat_history (Conversation): The conversation history
context (str): Creator provided context of the conversation
rules (list[str]): Creator provided rules
conversation_flow (str): Creator provided conversation flow
current_artifact (Artifact): The current artifact
req_settings (dict): The request settings
resource (GCResource): The resource object
Returns:
FunctionResult: The function result.
"""
# clear any pre-existing tools from the request settings
req_settings.tools = None
req_settings.tool_choice = None
# clear any extension data
if hasattr(req_settings, "extension_data"):
req_settings.extension_data = {}
kernel_function = kernel.add_function(
prompt=conversation_plan_template,
function_name="conversation_plan_function",
plugin_name="conversation_plan",
template_format="handlebars",
prompt_execution_settings=req_settings,
)
remaining_resource = resource.remaining_units
resource_instructions = resource.get_resource_instructions()
# if there is a resource constraint and there's more than one turn left, include the update agenda action
if (resource_instructions != "") and (remaining_resource > 1):
if resource.get_resource_mode() == ResourceConstraintMode.MAXIMUM:
total_resource_str = f"does not exceed the remaining turns ({remaining_resource})."
ample_time_str = ""
elif resource.get_resource_mode() == ResourceConstraintMode.EXACT:
total_resource_str = (
f"is equal to the remaining turns ({remaining_resource}). Do not leave any turns unallocated."
)
ample_time_str = """If you have many turns remaining, instead of including wrap-up items or repeating topics, you should include items that increase the breadth and/or depth of the conversation \
in a way that's directly relevant to the artifact (e.g. "collect additional details about X", "ask for clarification about Y", "explore related topic Z", etc.)."""
else:
logger.error("Invalid resource mode.")
else:
total_resource_str = ""
ample_time_str = ""
termination_instructions = _get_termination_instructions(resource)
# only include the agenda if there is a resource constraint and there's more than one turn left
show_agenda = resource_instructions != "" and remaining_resource > 1
arguments = KernelArguments(
context=context,
artifact_schema=current_artifact.get_schema_for_prompt(),
rules=" ".join([r.strip() for r in rules]),
current_state_description=conversation_flow,
show_agenda=show_agenda,
remaining_resource=remaining_resource,
total_resource_str=total_resource_str,
ample_time_str=ample_time_str,
termination_instructions=termination_instructions,
resource_instructions=resource_instructions,
chat_history=chat_history.get_repr_for_prompt(),
agenda_state=agenda.get_agenda_for_prompt(),
artifact_state=current_artifact.get_artifact_for_prompt(),
)
result = await kernel.invoke(function=kernel_function, arguments=arguments)
return result
def _get_termination_instructions(resource: GCResource):
"""
Get the termination instructions for the conversation. This is contingent on the resources mode,
if any, that is available.
Assumes we're always using turns as the resource unit.
Args:
resource (GCResource): The resource object.
Returns:
str: the termination instructions
"""
# Termination condition under no resource constraints
if resource.resource_constraint is None:
return "- You should pick this action as soon as you have completed the artifact to the best of your ability, \
the conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the conversation."
# Termination condition under exact resource constraints
if resource.resource_constraint.mode == ResourceConstraintMode.EXACT:
return (
"- You should only pick this action if the user is not cooperating so you cannot continue the conversation."
)
# Termination condition under maximum resource constraints
elif resource.resource_constraint.mode == ResourceConstraintMode.MAXIMUM:
return "- You should pick this action as soon as you have completed the artifact to the best of your ability, \
the conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the conversation."
else:
logger.error("Invalid resource mode provided.")
return ""
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Annotated
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
from semantic_kernel.functions import FunctionResult, KernelArguments
from semantic_kernel.functions.kernel_function_decorator import kernel_function
execution_template = """<message role="system">You are a helpful, thoughtful, and meticulous assistant.
You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation.
You will be given some reasoning about the best possible action(s) to take next given the state of the conversation as well as the artifact schema.
The reasoning is supposed to state the recommended action(s) to take next, along with all required parameters for each action.
Your task is to execute ALL actions recommended in the reasoning in the order they are listed.
If the reasoning's specification of an action is incomplete (e.g. it doesn't include all required parameters for the action, \
or some parameters are specified implicitly, such as "send a message that contains a greeting" instead of explicitly providing \
the value of the "message" parameter), do not execute the action. You should never fill in missing or imprecise parameters yourself.
If the reasoning is not clear about which actions to take, or all actions are specified in an incomplete way, \
return 'None' without selecting any action.</message>
<message role="user">Artifact schema:
{{ artifact_schema }}
If the type in the schema is str, the "field_value" parameter in the action should be also be a string.
These are example parameters for the update_artifact action: {"field_name": "company_name", "field_value": "Contoso"}
DO NOT write JSON in the "field_value" parameter in this case. {"field_name": "company_name", "field_value": "{"value": "Contoso"}"} is INCORRECT.
Reasoning:
{{ reasoning }}</message>"""
@kernel_function(name="send_message_to_user", description="Sends a message to the user.")
def send_message(message: Annotated[str, "The message to send to the user."]) -> None:
return None
@kernel_function(name="end_conversation", description="Ends the conversation.")
def end_conversation() -> None:
return None
async def execution(
kernel: Kernel, reasoning: str, filter: list[str], req_settings: PromptExecutionSettings, artifact_schema: str
) -> FunctionResult:
"""Executes the actions recommended by the reasoning/planning call in the given context.
Args:
kernel (Kernel): The kernel object.
reasoning (str): The reasoning from a previous model call.
filter (list[str]): The list of plugins to INCLUDE for the tool call.
req_settings (PromptExecutionSettings): The prompt execution settings.
artifact (str): The artifact schema for the execution prompt.
Returns:
FunctionResult: The result of the execution.
"""
filter = {"included_plugins": filter}
req_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(auto_invoke=False, filters=filter)
kernel_function = kernel.add_function(
prompt=execution_template,
function_name="execution",
plugin_name="execution",
template_format="handlebars",
prompt_execution_settings=req_settings,
)
arguments = KernelArguments(
artifact_schema=artifact_schema,
reasoning=reasoning,
)
result = await kernel.invoke(function=kernel_function, arguments=arguments)
return result
@@ -0,0 +1,99 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel import Kernel
from semantic_kernel.functions import FunctionResult, KernelArguments
from guided_conversation.utils.conversation_helpers import Conversation
final_update_template = """<message role="system">You are a helpful, thoughtful, and meticulous assistant.
You just finished a conversation with a user.{{#if context}} Here is some additional context about the conversation:
{{ context }}{{/if}}
Your goal is to complete an artifact as thoroughly and accurately as possible based on the conversation.
This is the schema of the artifact:
{{ artifact_schema }}
You will be given the current state of the artifact as well as the conversation history.
Note that if the value for a field in the artifact is 'Unanswered', it means that the field was not completed. \
Some fields may have already been completed during the conversation.
Your need to determine whether there are any fields that need to be updated, and if so, update them.
- You should only update a field if both of the following conditions are met: (a) the current state does NOT adequately reflect the conversation \
and (b) you are able to submit a valid value for a field. \
You are allowed to update completed fields, but you should only do so if the current state is inadequate, \
e.g. the user corrected a mistake in their date of birth, but the artifact does not show the corrected version. \
Remember that it's always an option to reset a field to "Unanswered" - this is often the best choice if the artifact contains incorrect information that cannot be corrected. \
Do not submit a value that is identical to the current state of the field (e.g. if the field is already "Unanswered" and the user didn't provide any new information about it, you should not submit "Unanswered"). \
- Make sure the value adheres to the constraints of the field as specified in the artifact schema. \
If it's not possible to update a field with a valid value (e.g., the user provided an invalid date of birth), you should not update the field.
- If the artifact schema is open-ended (e.g. it asks you to rate how pressing the user's issue is, without specifying rules for doing so), \
use your best judgment to determine whether you have enough information to complete the field based on the conversation.
- Prioritize accuracy over completion. You should never make up information or make assumptions in order to complete a field. \
For example, if the field asks for a 10-digit phone number, and the user provided a 9-digit phone number, you should not add a digit to the phone number in order to complete the field.
- If the user wasn't able to provide all of the information needed to complete a field, \
use your best judgment to determine if a partial answer is appropriate (assuming it adheres to the formatting requirements of the field). \
For example, if the field asks for a description of symptoms along with details about when the symptoms started, but the user wasn't sure when their symptoms started, \
it's better to record the information they do have rather than to leave the field unanswered (and to indicate that the user was unsure about the start date).
- It's possible to update multiple fields at once (assuming you're adhering to the above rules in all cases). It's also possible that no fields need to be updated.
Your task is to state your step-by-step reasoning about what to update, followed by a final recommendation.
Someone else will be responsible for executing the updates and they will only have access to your output \
(not any of the conversation history, artifact schema, or other context) so make sure to specify exactly which \
fields to update and the values to update them with, or to state that no fields need to be updated.
</message>
<message role="user">Conversation history:
{{ conversation_history }}
Current state of the artifact:
{{ artifact_state }}</message>"""
async def final_update_plan_function(
kernel: Kernel,
req_settings: dict,
chat_history: Conversation,
context: str,
artifact_schema: str,
artifact_state: str,
) -> FunctionResult:
"""This function is responsible for updating the artifact based on the conversation history when the conversation ends. This function may not always update the artifact, namely if the current state of the artifact is already accurate based on the conversation history. The function will return a step-by-step reasoning about what to update, followed by a final recommendation. The final recommendation will specify exactly which fields to update and the values to update them with, or to state that no fields need to be updated.
Args:
kernel (Kernel): The kernel object.
req_settings (dict): The prompt execution settings.
chat_history (Conversation): The conversation history.
context (str): The context of the conversation.
artifact_schema (str): The schema of the artifact.
artifact_state (str): The current state of the artifact.
Returns:
FunctionResult: The result of the function (step-by-step reasoning about what to update in the artifact)
"""
req_settings.tools = None
req_settings.tool_choice = None
# clear any extension data
if hasattr(req_settings, "extension_data"):
req_settings.extension_data = {}
kernel_function = kernel.add_function(
prompt=final_update_template,
function_name="final_update_plan_function",
plugin_name="final_update_plan",
template_format="handlebars",
prompt_execution_settings=req_settings,
)
arguments = KernelArguments(
conversation_history=chat_history.get_repr_for_prompt(),
context=context,
artifact_schema=artifact_schema,
artifact_state=artifact_state,
)
result = await kernel.invoke(function=kernel_function, arguments=arguments)
return result
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,253 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Annotated
from pydantic import Field, ValidationError
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.functions import KernelArguments
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from guided_conversation.utils.base_model_llm import BaseModelLLM
from guided_conversation.utils.conversation_helpers import Conversation, ConversationMessageType
from guided_conversation.utils.openai_tool_calling import ToolValidationResult
from guided_conversation.utils.plugin_helpers import PluginOutput, fix_error, update_attempts
from guided_conversation.utils.resources import ResourceConstraintMode, ResourceConstraintUnit, format_resource
AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE = """<message role="system">You are a helpful, thoughtful, and meticulous assistant.
You are conducting a conversation with a user. You tried to update the agenda, but the update was invalid.
You will be provided the history of your conversation with the user, \
your previous attempt(s) at updating the agenda, and the error message(s) that resulted from your attempt(s).
Your task is to correct the update so that it is valid. \
Your changes should be as minimal as possible - you are focused on fixing the error(s) that caused the update to be invalid.
Note that if the resource allocation is invalid, you must follow these rules:
1. You should not change the description of the first item (since it has already been executed), but you can change its resource allocation
2. For all other items, you can combine or split them, or assign them fewer or more resources, \
but the content they cover collectively should not change (i.e. don't eliminate or add new topics).
For example, the invalid attempt was "item 1 = ask for date of birth (1 turn), item 2 = ask for phone number (1 turn), \
item 3 = ask for phone type (1 turn), item 4 = explore treatment history (6 turns)", \
and the error says you need to correct the total resource allocation to 7 turns. \
A bad solution is "item 1 = ask for date of birth (1 turn), \
item 2 = explore treatment history (6 turns)" because it eliminates the phone number and phone type topics. \
A good solution is "item 1 = ask for date of birth (2 turns), item 2 = ask for phone number, phone type,
and treatment history (2 turns), item 3 = explore treatment history (3 turns)."</message>
<message role="user">Conversation history:
{{ conversation_history }}
Previous attempts to update the agenda:
{{ previous_attempts }}</message>"""
UPDATE_AGENDA_TOOL = "update_agenda"
class _BaseAgendaItem(BaseModelLLM):
title: str = Field(description="Brief description of the item")
resource: int = Field(description="Number of turns required for the item")
class _BaseAgenda(BaseModelLLM):
items: list[_BaseAgendaItem] = Field(
description="Ordered list of items to be completed in the remainder of the conversation",
default_factory=list,
)
class Agenda:
"""An abstraction to manage a conversation agenda. The expected use case is that another agent will generate an agenda.
This class will validate if it is valid, and help correct it if it is not.
Args:
kernel (Kernel): The Semantic Kernel instance to use for calling the LLM. Don't forget to set your
req_settings since this class uses tool calling functionality from the Semantic Kernel.
service_id (str): The service ID to use for the Semantic Kernel tool calling. One kernel can have multiple
services. The service ID is used to identify which service to use for LLM calls. The Agenda object
assumes that the service has tool calling capabilities and is some flavor of chat completion.
resource_constraint_mode (ResourceConstraintMode): The mode for resource constraints.
max_agenda_retries (int): The maximum number of retries for updating the agenda.
"""
def __init__(
self,
kernel: Kernel,
service_id: str,
resource_constraint_mode: ResourceConstraintMode | None,
max_agenda_retries: int = 2,
) -> None:
logger = logging.getLogger(__name__)
self.id = "agenda_plugin"
self.kernel = Kernel()
self.logger = logger
self.kernel = kernel
self.service_id = service_id
self.resource_constraint_mode = resource_constraint_mode
self.max_agenda_retries = max_agenda_retries
self.agenda = _BaseAgenda()
async def update_agenda(
self,
items: list[dict[str, str]],
remaining_turns: int,
conversation: Conversation,
) -> PluginOutput:
"""Updates the agenda model with the given items (generally generated by an LLM) and validates if the update is valid.
The agenda update reasons in terms of turns for validating the if the proposed agenda is valid.
If you wish to use a different resource unit, convert the value to turns in some way because
we found that LLMs do much better at reasoning in terms of turns.
Args:
items (list[dict[str, str]]): A list of agenda items.
Each item should have the following keys:
- title (str): A brief description of the item.
- resource (int): The number of turns required for the item.
remaining_turns (int): The number of remaining turns.
conversation (Conversation): The conversation object.
Returns:
PluginOutput: A PluginOutput object with the success status. Does not generate any messages.
"""
previous_attempts = []
while True:
try:
# Try to update the agenda, and do extra validation checks
self.agenda.items = items
self._validate_agenda_update(items, remaining_turns)
self.logger.info(f"Agenda updated successfully: {self.get_agenda_for_prompt()}")
return PluginOutput(True, [])
except (ValidationError, ValueError) as e:
# Update the previous attempts and get instructions for the LLM
previous_attempts, llm_formatted_attempts = update_attempts(
error=e, attempt_id=str(items), previous_attempts=previous_attempts
)
# If we have reached the maximum number of retries return a failure
if len(previous_attempts) > self.max_agenda_retries:
self.logger.warning(f"Failed to update agenda after {self.max_agenda_retries} attempts.")
return PluginOutput(False, [])
else:
self.logger.info(f"Attempting to fix the agenda error. Attempt {len(previous_attempts)}.")
response = await self._fix_agenda_error(llm_formatted_attempts, conversation)
if response["validation_result"] != ToolValidationResult.SUCCESS:
self.logger.warning(
f"Failed to fix the agenda error due to a failure in the LLM tool call: {response['validation_result']}"
)
return PluginOutput(False, [])
else:
# Use the result of the first tool call to try the update again
items = response["tool_args_list"][0]["items"]
def get_agenda_for_prompt(self) -> str:
"""Gets a string representation of the agenda for use in an LLM prompt.
Returns:
str: A string representation of the agenda.
"""
agenda_json = self.agenda.model_dump()
agenda_items = agenda_json.get("items", [])
if len(agenda_items) == 0:
return "None"
agenda_str = "\n".join(
[
f"{i + 1}. [{format_resource(item['resource'], ResourceConstraintUnit.TURNS)}] {item['title']}"
for i, item in enumerate(agenda_items)
]
)
total_resource = format_resource(sum([item["resource"] for item in agenda_items]), ResourceConstraintUnit.TURNS)
agenda_str += f"\nTotal = {total_resource}"
return agenda_str
# The following is the kernel function that will be provided to the LLM call
class Items:
title: Annotated[str, "Description of the item"]
resource: Annotated[int, "Number of turns required for the item"]
@kernel_function(
name=UPDATE_AGENDA_TOOL,
description="Updates the agenda.",
)
def update_agenda_items(
self,
items: Annotated[list[Items], "Ordered list of items to be completed in the remainder of the conversation"],
):
pass
async def _fix_agenda_error(self, previous_attempts: str, conversation: Conversation) -> None:
"""Calls an LLM to try and fix an error in the agenda update."""
req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
req_settings.max_tokens = 2000
self.kernel.add_function(plugin_name=self.id, function=self.update_agenda_items)
filter = {"included_plugins": [self.id]}
req_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(auto_invoke=False, filters=filter)
arguments = KernelArguments(
conversation_history=conversation.get_repr_for_prompt(exclude_types=[ConversationMessageType.REASONING]),
previous_attempts=previous_attempts,
)
return await fix_error(
kernel=self.kernel,
prompt_template=AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE,
req_settings=req_settings,
arguments=arguments,
)
def _validate_agenda_update(self, items: list[dict[str, str]], remaining_turns: int) -> None:
"""Validates if any constraints were violated while performing the agenda update.
Args:
items (list[dict[str, str]]): A list of agenda items.
remaining_turns (int): The number of remaining turns.
Raises:
ValueError: If any validation checks fail.
"""
# The total, proposed allocation of resources.
total_resources = sum([item["resource"] for item in items])
violations = []
# In maximum mode, the total resources should not exceed the remaining turns
if (self.resource_constraint_mode == ResourceConstraintMode.MAXIMUM) and (total_resources > remaining_turns):
total_resource_instruction = (
f"The total turns allocated in the agenda must not exceed the remaining amount ({remaining_turns})"
)
violations.append(f"{total_resource_instruction}; but the current total is {total_resources}.")
# In exact mode if the total resources were not exactly equal to the remaining turns
if (self.resource_constraint_mode == ResourceConstraintMode.EXACT) and (total_resources != remaining_turns):
total_resource_instruction = (
f"The total turns allocated in the agenda must equal the remaining amount ({remaining_turns})"
)
violations.append(f"{total_resource_instruction}; but the current total is {total_resources}.")
# Check if any item has a resource value of 0
if any(item["resource"] <= 0 for item in items):
violations.append("All items must have a resource value greater than 0.")
# Raise an error if any violations were found
if len(violations) > 0:
self.logger.debug(f"Agenda update failed due to the following violations: {violations}.")
raise ValueError(" ".join(violations))
def to_json(self) -> dict:
agenda_dict = self.agenda.model_dump()
return {
"agenda": agenda_dict,
}
@classmethod
def from_json(
cls,
json_data: dict,
kernel: Kernel,
service_id: str,
resource_constraint_mode: ResourceConstraintMode | None,
max_agenda_retries: int = 2,
) -> "Agenda":
agenda = cls(kernel, service_id, resource_constraint_mode, max_agenda_retries)
agenda.agenda.items = json_data["agenda"]["items"]
return agenda
@@ -0,0 +1,480 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Annotated, Any, Literal, get_args, get_origin, get_type_hints
from pydantic import BaseModel, create_model
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.contents import AuthorRole, ChatMessageContent
from semantic_kernel.functions import KernelArguments
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from guided_conversation.utils.base_model_llm import BaseModelLLM
from guided_conversation.utils.conversation_helpers import Conversation, ConversationMessageType
from guided_conversation.utils.openai_tool_calling import ToolValidationResult
from guided_conversation.utils.plugin_helpers import PluginOutput, fix_error, update_attempts
ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE = """<message role="system">You are a helpful, thoughtful, and meticulous assistant.
You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation.
You have tried to update a field in the artifact, but the value you provided did not adhere \
to the constraints of the field as specified in the artifact schema.
You will be provided the history of your conversation with the user, the schema for the field, \
your previous attempt(s) at updating the field, and the error message(s) that resulted from your attempt(s).
Your task is to select the best possible action to take next:
1. Update artifact
- You should pick this action if you have a valid value to submit for the field in question.
2. Resume conversation
- You should pick this action if: (a) you do NOT have a valid value to submit for the field in question, and \
(b) you need to ask the user for more information in order to obtain a valid value. \
For example, if the user stated that their date of birth is June 2000, but the artifact field asks for the date of birth in the format \
"YYYY-MM-DD", you should resume the conversation and ask the user for the day.</message>
<message role="user">Conversation history:
{{ conversation_history }}
Schema:
{{ artifact_schema }}
Previous attempts to update the field "{{ field_name }}" in the artifact:
{{ previous_attempts }}</message>"""
UPDATE_ARTIFACT_TOOL = "update_artifact_field"
RESUME_CONV_TOOL = "resume_conversation"
class Artifact:
"""The Artifact plugin takes in a Pydantic base model, and robustly handles updating the fields of the model
A typical use case is as a form an agent must complete throughout a conversation.
Another use case is as a working memory for the agent.
The primary interface is update_artifact, which takes in the field_name to update and its new value.
Additionally, the chat_history is passed in to help the agent make informed decisions in case an error occurs.
The Artifact also exposes several functions to access internal state:
get_artifact_for_prompt, get_schema_for_prompt, and get_failed_fields.
"""
def __init__(
self, kernel: Kernel, service_id: str, input_artifact: BaseModel, max_artifact_field_retries: int = 2
) -> None:
"""
Initialize the Artifact plugin with the given Pydantic base model.
Args:
kernel (Kernel): The Semantic Kernel instance to use for calling the LLM. Don't forget to set your
req_settings since this class uses tool calling functionality from the Semantic Kernel.
service_id (str): The service ID to use for the Semantic Kernel tool calling. One kernel can have multiple
services. The service ID is used to identify which service to use for LLM calls. The Artifact object
assumes that the service has tool calling capabilities and is some flavor of chat completion.
input_artifact (BaseModel): The Pydantic base model to use as the artifact
max_artifact_field_retries (int): The maximum number of times to retry updating a field in the artifact
"""
logger = logging.getLogger(__name__)
self.logger = logger
self.id = "artifact_plugin"
self.kernel = kernel
self.service_id = service_id
self.max_artifact_field_retries = max_artifact_field_retries
self.original_schema = input_artifact.model_json_schema()
self.artifact = self._initialize_artifact(input_artifact)
# failed_artifact_fields maps a field name to a list of the history of the failed attempts to update it
# dict: key = field, value = list of tuple[attempt, error message]
self.failed_artifact_fields: dict[str, list[tuple[str, str]]] = {}
# The following are the kernel functions that will be provided to the LLM call
@kernel_function(
name=UPDATE_ARTIFACT_TOOL,
description="Sets the value of a field in the artifact",
)
def update_artifact_field(
self,
field: Annotated[str, "The name of the field to update in the artifact"],
value: Annotated[str, "The value to set the field to"],
) -> None:
pass
@kernel_function(
name=RESUME_CONV_TOOL,
description="Resumes conversation to get more information from the user ",
)
def resume_conversation(self):
pass
async def update_artifact(self, field_name: str, field_value: Any, conversation: Conversation) -> PluginOutput:
"""The core interface for the Artifact plugin.
This function will attempt to update the given field_name to the given field_value.
If the field_value fails Pydantic validation, an LLM will determine one of two actions to take.
Given the conversation as additional context the two actions are:
- Retry the update the artifact by fixing the formatting using the previous failed attempts as guidance
- Take no action or in other words, resume the conversation to ask the user for more information because the user gave incomplete or incorrect information
Args:
field_name (str): The name of the field to update in the artifact
field_value (Any): The value to set the field to
conversation (Conversation): The conversation object that contains the history of the conversation
Returns:
PluginOutput: An object with two fields: a boolean indicating success
and a list of conversation messages that may have been generated.
Several outcomes can happen:
- The update may have failed due to
- A field_name that is not valid in the artifact.
- The field_value failing Pydantic validation and all retries failed.
- The model failed to correctly call a tool.
In this case, the boolean will be False and the list may contain a message indicating the failure.
- The agent may have successfully updated the artifact or fixed it.
In this case, the boolean will be True and the list will contain a message indicating the update and possibly intermediate messages.
- The agent may have decided to resume the conversation.
In this case, the boolean will be True and the messages may only contain messages indicated previous errors.
"""
conversation_messages: list[ChatMessageContent] = []
# Check if the field name is valid, and return with a failure message if not
is_valid_field, msg = self._is_valid_field(field_name)
if not is_valid_field:
conversation_messages.append(msg)
return PluginOutput(update_successful=False, messages=conversation_messages)
# Try to update the field, and handle any errors that occur until the field is
# successfully updated or skipped according to max_artifact_field_retries
while True:
try:
# Check if there have been too many previous failed attempts to update the field
if len(self.failed_artifact_fields.get(field_name, [])) >= self.max_artifact_field_retries:
self.logger.warning(f"Updating field {field_name} has failed too many times. Skipping.")
return False, conversation_messages
# Attempt to update the artifact
msg = self._execute_update_artifact(field_name, field_value)
conversation_messages.append(msg)
return PluginOutput(True, conversation_messages)
except Exception as e:
self.logger.warning(f"Error updating field {field_name}: {e}. Retrying...")
# Handle update error will increment failed_artifact_fields, once it has failed
# greater than self.max_artifact_field_retries the field will be skipped and the loop will break
success, new_field_value = await self._handle_update_error(field_name, field_value, conversation, e)
# The agent has successfully fixed the field.
if success and new_field_value is not None:
self.logger.info(f"Agent successfully fixed field {field_name}. New value: {new_field_value}")
field_value = new_field_value
# This is the case where the agent has decided to resume the conversation.
elif success:
self.logger.info(
f"Agent could not fix the field itself & decided to resume conversation to fix field {field_name}"
)
return PluginOutput(True, conversation_messages)
self.logger.warning(f"Agent failed to fix field {field_name}. Retrying...")
# Otherwise, the agent has failed and we will go through the loop again
def get_artifact_for_prompt(self) -> str:
"""Returns a formatted JSON-like representation of the current state of the fields artifact.
Any fields that were failed are completely omitted.
Returns:
str: The string representation of the artifact.
"""
failed_fields = self.get_failed_fields()
return {k: v for k, v in self.artifact.model_dump().items() if k not in failed_fields}
def get_schema_for_prompt(self, filter_one_field: str | None = None) -> str:
"""Gets a clean version of the original artifact schema, optimized for use in an LLM prompt.
Args:
filter_one_field (str | None): If this is provided, only the schema for this one field will be returned.
Returns:
str: The cleaned schema
"""
def _clean_properties(schema: dict, failed_fields: list[str]) -> str:
properties = schema.get("properties", {})
clean_properties = {}
for name, property_dict in properties.items():
if name not in failed_fields:
cleaned_property = {}
for k, v in property_dict.items():
if k in ["title", "default"]:
continue
cleaned_property[k] = v
clean_properties[name] = cleaned_property
clean_properties_str = str(clean_properties)
clean_properties_str = clean_properties_str.replace("$ref", "type")
clean_properties_str = clean_properties_str.replace("#/$defs/", "")
return clean_properties_str
# If filter_one_field is provided, only get the schema for that one field
if filter_one_field:
if not self._is_valid_field(filter_one_field):
self.logger.error(f'Field "{filter_one_field}" is not a valid field in the artifact.')
raise ValueError(f'Field "{filter_one_field}" is not a valid field in the artifact.')
filtered_schema = {"properties": {filter_one_field: self.original_schema["properties"][filter_one_field]}}
filtered_schema.update((k, v) for k, v in self.original_schema.items() if k != "properties")
schema = filtered_schema
else:
schema = self.original_schema
failed_fields = self.get_failed_fields()
properties = _clean_properties(schema, failed_fields)
if not properties:
self.logger.error("No properties found in the schema.")
raise ValueError("No properties found in the schema.")
types_schema = schema.get("$defs", {})
custom_types = []
for type_name, type_info in types_schema.items():
if f"'type': '{type_name}'" in properties:
clean_schema = _clean_properties(type_info, [])
if clean_schema != "{}":
custom_types.append(f"{type_name} = {clean_schema}")
if custom_types:
explanation = f"If you wanted to create a {type_name} object, for example, you would make a JSON object \
with the following keys: {', '.join(types_schema[type_name]['properties'].keys())}."
custom_types_str = "\n".join(custom_types)
return f"""{properties}
Here are the definitions for the custom types referenced in the artifact schema:
{custom_types_str}
{explanation}
Remember that when updating the artifact, the field will be the original field name in the artifact and the JSON object(s) will be the value."""
else:
return properties
def get_failed_fields(self) -> list[str]:
"""Get a list of fields that have failed all attempts to update.
Returns:
list[str]: A list of field names that have failed all attempts to update.
"""
fields = []
for field, attempts in self.failed_artifact_fields.items():
if len(attempts) >= self.max_artifact_field_retries:
fields.append(field)
return fields
def _initialize_artifact(self, artifact_model: BaseModel) -> BaseModelLLM:
"""Create a new artifact model based on the one provided by the user
with "Unanswered" set for all fields.
Args:
artifact_model (BaseModel): The Pydantic class provided by the user
Returns:
BaseModelLLM: The new artifact model with "Unanswered" set for all fields
"""
modified_classes = self._modify_classes(artifact_model)
artifact = self._modify_base_artifact(artifact_model, modified_classes)
return artifact()
def _get_type_if_subtype(self, target_type: type[Any], base_type: type[Any]) -> type[Any] | None:
"""Recursively checks the target_type to see if it is a subclass of base_type or a generic including base_type.
Args:
target_type: The type to check.
base_type: The type to check against.
Returns:
The class type if target_type is base_type, a subclass of base_type, or a generic including base_type; otherwise, None.
"""
origin = get_origin(target_type)
if origin is None:
if issubclass(target_type, base_type):
return target_type
else:
# Recursively check if any of the arguments are the target type
for arg in get_args(target_type):
result = self._get_type_if_subtype(arg, base_type)
if result is not None:
return result
return None
def _modify_classes(self, artifact_class: BaseModel) -> dict[str, type[BaseModelLLM]]:
"""Find all classes used as type hints in the artifact, and modify them to set 'Unanswered' as a default and valid value for all fields."""
modified_classes = {}
# Find any instances of BaseModel in the artifact class in the first "level" of type hints
for field_name, field_type in get_type_hints(artifact_class).items():
is_base_model = self._get_type_if_subtype(field_type, BaseModel)
if is_base_model is not None:
modified_classes[field_name] = self._modify_base_artifact(is_base_model)
return modified_classes
def _replace_type_annotations(
self, field_annotation: type[Any] | None, modified_classes: dict[str, type[BaseModelLLM]]
) -> type:
"""Recursively replace type annotations with modified classes where applicable."""
# Get the origin of the field annotation, which is the base type for generic types (e.g., List[str] -> list, Dict[str, int] -> dict)
origin = get_origin(field_annotation)
# Get the type arguments of the generic type (e.g., List[str] -> str, Dict[str, int] -> str, int)
args = get_args(field_annotation)
if origin is None:
# The type is not generic; check if it's a subclass that needs to be replaced
if isinstance(field_annotation, type) and issubclass(field_annotation, BaseModelLLM):
return modified_classes.get(field_annotation.__name__, field_annotation)
return field_annotation
else:
# The type is generic; recursively replace the type annotations of the arguments
new_args = tuple(self._replace_type_annotations(arg, modified_classes) for arg in args)
return origin[new_args]
def _modify_base_artifact(
self, artifact_model: type[BaseModelLLM], modified_classes: dict[str, type[BaseModelLLM]] | None = None
) -> type[BaseModelLLM]:
"""Create a new artifact model with 'Unanswered' as a default and valid value for all fields."""
for _, field_info in artifact_model.model_fields.items():
# Replace original classes with modified version
if modified_classes is not None:
field_info.annotation = self._replace_type_annotations(field_info.annotation, modified_classes)
# This makes it possible to always set a field to "Unanswered"
field_info.annotation = field_info.annotation | Literal["Unanswered"]
# This sets the default value to "Unanswered"
field_info.default = "Unanswered"
# This adds "Unanswered" as a possible value to any regex patterns
metadata = field_info.metadata
for m in metadata:
if hasattr(m, "pattern"):
m.pattern += "|Unanswered"
field_definitions = {
name: (field_info.annotation, field_info) for name, field_info in artifact_model.model_fields.items()
}
artifact_model = create_model("Artifact", __base__=BaseModelLLM, **field_definitions)
return artifact_model
def _is_valid_field(self, field_name: str) -> tuple[bool, ChatMessageContent]:
"""Check if the field_name is a valid field in the artifact. Returns True if it is, False and an error message otherwise."""
if field_name not in self.artifact.model_fields:
error_message = f'Field "{field_name}" is not a valid field in the artifact.'
msg = ChatMessageContent(
role=AuthorRole.ASSISTANT,
content=error_message,
metadata={"type": ConversationMessageType.ARTIFACT_UPDATE, "turn_number": None},
)
return False, msg
return True, None
async def _fix_artifact_error(
self,
field_name: str,
previous_attempts: str,
conversation_repr: str,
artifact_schema_repr: str,
) -> dict[str, Any]:
"""Calls the LLM to fix an error in the artifact using Semantic Kernel kernel."""
req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
req_settings.max_tokens = 2000
self.kernel.add_function(plugin_name=self.id, function=self.update_artifact_field)
self.kernel.add_function(plugin_name=self.id, function=self.resume_conversation)
filter = {"included_plugins": [self.id]}
req_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(auto_invoke=False, filters=filter)
arguments = KernelArguments(
field_name=field_name,
conversation_history=conversation_repr,
previous_attempts=previous_attempts,
artifact_schema=artifact_schema_repr,
settings=req_settings,
)
return await fix_error(
kernel=self.kernel,
prompt_template=ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE,
req_settings=req_settings,
arguments=arguments,
)
def _execute_update_artifact(
self,
field_name: Annotated[str, "The name of the field to update in the artifact"],
field_value: Annotated[Any, "The value to set the field to"],
) -> None:
"""Update a field in the artifact with a new value. This will raise an error if the field_value is invalid."""
setattr(self.artifact, field_name, field_value)
msg = ChatMessageContent(
role=AuthorRole.ASSISTANT,
content=f"Assistant updated {field_name} to {field_value}",
metadata={"type": ConversationMessageType.ARTIFACT_UPDATE, "turn_number": None},
)
return msg
async def _handle_update_error(
self, field_name: str, field_value: Any, conversation: Conversation, error: Exception
) -> tuple[bool, Any]:
"""
Handles the logic for when an error occurs while updating a field.
Creates the appropriate context for the model and calls the LLM to fix the error.
Args:
field_name (str): The name of the field to update in the artifact
field_value (Any): The value to set the field to
conversation (Conversation): The conversation object that contains the history of the conversation
error (Exception): The error that occurred while updating the field
Returns:
tuple[bool, Any]: A tuple containing a boolean indicating success and the new field value if successful (if not, then None)
"""
# Update the failed attempts for the field
previous_attempts = self.failed_artifact_fields.get(field_name, [])
previous_attempts, llm_formatted_attempts = update_attempts(
error=error, attempt_id=str(field_value), previous_attempts=previous_attempts
)
self.failed_artifact_fields[field_name] = previous_attempts
# Call the LLM to fix the error
conversation_history_repr = conversation.get_repr_for_prompt(exclude_types=[ConversationMessageType.REASONING])
artifact_schema_repr = self.get_schema_for_prompt(filter_one_field=field_name)
result = await self._fix_artifact_error(
field_name, llm_formatted_attempts, conversation_history_repr, artifact_schema_repr
)
# Handling the result of the LLM call
if result["validation_result"] != ToolValidationResult.SUCCESS:
return False, None
# Only consider the first tool call
tool_name = result["tool_names"][0]
tool_args = result["tool_args_list"][0]
if tool_name == f"{self.id}-{UPDATE_ARTIFACT_TOOL}":
field_value = tool_args["value"]
return True, field_value
elif tool_name == f"{self.id}-{RESUME_CONV_TOOL}":
return True, None
def to_json(self) -> dict:
artifact_fields = self.artifact.model_dump()
return {
"artifact": artifact_fields,
"failed_fields": self.failed_artifact_fields,
}
@classmethod
def from_json(
cls,
json_data: dict,
kernel: Kernel,
service_id: str,
input_artifact: BaseModel,
max_artifact_field_retries: int = 2,
) -> "Artifact":
artifact = cls(kernel, service_id, input_artifact, max_artifact_field_retries)
artifact.failed_artifact_fields = json_data["failed_fields"]
# Iterate over artifact fields and set them to the values in the json data
# Skip any fields that are set as "Unanswered"
for field_name, field_value in json_data["artifact"].items():
if field_value != "Unanswered":
setattr(artifact.artifact, field_name, field_value)
return artifact
@@ -0,0 +1,390 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
import logging
from pydantic import BaseModel
from semantic_kernel import Kernel
from semantic_kernel.contents import AuthorRole, ChatMessageContent
from semantic_kernel.functions import KernelArguments
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from guided_conversation.functions.conversation_plan import conversation_plan_function
from guided_conversation.functions.execution import end_conversation, execution, send_message
from guided_conversation.functions.final_update_plan import final_update_plan_function
from guided_conversation.plugins.agenda import Agenda
from guided_conversation.plugins.artifact import Artifact
from guided_conversation.utils.conversation_helpers import Conversation, ConversationMessageType
from guided_conversation.utils.openai_tool_calling import (
ToolValidationResult,
parse_function_result,
validate_tool_calling,
)
from guided_conversation.utils.plugin_helpers import PluginOutput, format_kernel_functions_as_tools
from guided_conversation.utils.resources import GCResource, ResourceConstraint
MAX_DECISION_RETRIES = 2
class ToolName(Enum):
UPDATE_ARTIFACT_TOOL = "update_artifact_field"
UPDATE_AGENDA_TOOL = "update_agenda"
SEND_MSG_TOOL = "send_message_to_user"
END_CONV_TOOL = "end_conversation"
GENERATE_PLAN_TOOL = "generate_plan"
EXECUTE_PLAN_TOOL = "execute_plan"
FINAL_UPDATE_TOOL = "final_update"
GUIDED_CONVERSATION_AGENT_TOOLBOX = "gc_agent"
@dataclass
class GCOutput:
"""The output of the GuidedConversation agent.
Args:
ai_message (str): The message to send to the user.
is_conversation_over (bool): Whether the conversation is over.
"""
ai_message: str | None = field(default=None)
is_conversation_over: bool = field(default=False)
class GuidedConversation:
def __init__(
self,
kernel: Kernel,
artifact: BaseModel,
rules: list[str],
conversation_flow: str | None,
context: str | None,
resource_constraint: ResourceConstraint | None,
service_id: str = "gc_main",
) -> None:
"""Initializes the GuidedConversation agent.
Args:
kernel (Kernel): An instance of Kernel. Must come initialized with a AzureOpenAI or OpenAI service.
artifact (BaseModel): The artifact to be used as the goal/working memory/output of the conversation.
rules (list[str]): The rules to be used in the guided conversation (dos and donts).
conversation_flow (str | None): The conversation flow to be used in the guided conversation.
context (str | None): The scene-setting for the conversation.
resource_constraint (ResourceConstraint | None): The limit on the conversation length (for ex: number of turns).
service_id (str): Provide a service_id associated with the kernel's service that was provided.
"""
self.logger = logging.getLogger(__name__)
self.kernel = kernel
self.service_id = service_id
self.conversation = Conversation()
self.resource = GCResource(resource_constraint)
self.artifact = Artifact(self.kernel, self.service_id, artifact)
self.rules = rules
self.conversation_flow = conversation_flow
self.context = context
self.agenda = Agenda(self.kernel, self.service_id, self.resource.get_resource_mode(), MAX_DECISION_RETRIES)
# Plugins will be executed in the order of this list.
self.plugins_order = [
ToolName.UPDATE_ARTIFACT_TOOL.value,
ToolName.UPDATE_AGENDA_TOOL.value,
]
# Terminal plugins are plugins that are handled in a special way:
# - Only one terminal plugin can be called in a single step of the conversation as it leads to the end of the conversation step.
# - The order of this list determines the execution priority.
# - For example, if the model chooses to both call send message and end conversation,
# Send message will be executed first and since the orchestration step returns, end conversation will not be executed.
self.terminal_plugins_order = [
ToolName.SEND_MSG_TOOL.value,
ToolName.END_CONV_TOOL.value,
]
self.current_failed_decision_attempts = 0
# Set common request settings
self.req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
self.req_settings.max_tokens = 2000
self.kernel.add_function(plugin_name=ToolName.SEND_MSG_TOOL.value, function=send_message)
self.kernel.add_function(plugin_name=ToolName.END_CONV_TOOL.value, function=end_conversation)
self.kernel.add_function(
plugin_name=ToolName.UPDATE_ARTIFACT_TOOL.value, function=self.artifact.update_artifact_field
)
self.kernel.add_function(
plugin_name=ToolName.UPDATE_AGENDA_TOOL.value, function=self.agenda.update_agenda_items
)
# Set orchestrator functions for the agent
self.kernel_function_generate_plan = self.kernel.add_function(
plugin_name="gc_agent", function=self.generate_plan
)
self.kernel_function_execute_plan = self.kernel.add_function(plugin_name="gc_agent", function=self.execute_plan)
self.kernel_function_final_update = self.kernel.add_function(plugin_name="gc_agent", function=self.final_update)
async def step_conversation(self, user_input: str | None = None) -> GCOutput:
"""Given a message from a user, this will execute the guided conversation agent up until a
terminal plugin is called or the maximum number of decision retries is reached."""
self.logger.info(f"Starting conversation step {self.resource.turn_number}.")
self.resource.start_resource()
self.current_failed_decision_attempts = 0
if user_input:
self.conversation.add_messages(
ChatMessageContent(
role=AuthorRole.USER,
content=user_input,
metadata={"turn_number": self.resource.turn_number, "type": ConversationMessageType.DEFAULT},
)
)
# Keep generating and executing plans until a terminal plugin is called
# or the maximum number of decision retries is reached.
while self.current_failed_decision_attempts < MAX_DECISION_RETRIES:
plan = await self.kernel.invoke(self.kernel_function_generate_plan)
executed_plan = await self.kernel.invoke(
self.kernel_function_execute_plan, KernelArguments(plan=plan.value)
)
success, plugins, terminal_plugins = executed_plan.value
if success != ToolValidationResult.SUCCESS:
self.logger.warning(
f"Failed to parse tools in plan on retry attempt {self.current_failed_decision_attempts} out of {MAX_DECISION_RETRIES}."
)
self.current_failed_decision_attempts += 1
continue
# Run a step of the orchestration logic based on the plugins called by the model.
# First execute all regular plugins (if any) in the order returned by execute_plan
for plugin_name, plugin_args in plugins:
if plugin_name == f"{ToolName.UPDATE_ARTIFACT_TOOL.value}-{ToolName.UPDATE_ARTIFACT_TOOL.value}":
plugin_args["conversation"] = self.conversation
# Modify plugin_args such that field=field_name and value=field_value
plugin_args["field_name"] = plugin_args.pop("field")
plugin_args["field_value"] = plugin_args.pop("value")
await self._call_plugin(self.artifact.update_artifact, plugin_args)
elif plugin_name == f"{ToolName.UPDATE_AGENDA_TOOL.value}-{ToolName.UPDATE_AGENDA_TOOL.value}":
plugin_args["remaining_turns"] = self.resource.get_remaining_turns()
plugin_args["conversation"] = self.conversation
await self._call_plugin(self.agenda.update_agenda, plugin_args)
# Then execute the first terminal plugin (if any)
if terminal_plugins:
gc_output = GCOutput()
plugin_name, plugin_args = terminal_plugins[0]
if plugin_name == f"{ToolName.SEND_MSG_TOOL.value}-{ToolName.SEND_MSG_TOOL.value}":
gc_output.ai_message = plugin_args["message"]
elif plugin_name == f"{ToolName.END_CONV_TOOL.value}-{ToolName.END_CONV_TOOL.value}":
await self.kernel.invoke(self.kernel_function_final_update)
gc_output.ai_message = "I will terminate this conversation now. Thank you for your time!"
gc_output.is_conversation_over = True
self.resource.increment_resource()
return gc_output
# Handle case where the maximum number of decision retries was reached.
self.logger.warning(f"Failed to execute plan after {MAX_DECISION_RETRIES} attempts.")
self.resource.increment_resource()
gc_output = GCOutput()
gc_output.ai_message = "An error occurred and I must sadly end the conversation."
gc_output.is_conversation_over = True
return gc_output
@kernel_function(
name=ToolName.GENERATE_PLAN_TOOL.value,
description="Generate a plan based on a time constraint for the current state of the conversation.",
)
async def generate_plan(self) -> str:
"""Generate a plan for the current state of the conversation. The idea here is to explicitly let the model plan before
generating any plugin calls. This has been shown to increase reliability.
Returns:
str: The plan generated by the plan function.
"""
self.logger.info("Generating plan for the current state of the conversation")
plan = await conversation_plan_function(
self.kernel,
self.conversation,
self.context,
self.rules,
self.conversation_flow,
self.artifact,
self.req_settings,
self.resource,
self.agenda,
)
plan = plan.value[0].content
self.conversation.add_messages(
ChatMessageContent(
role=AuthorRole.ASSISTANT,
content=plan,
metadata={"turn_number": self.resource.turn_number, "type": ConversationMessageType.REASONING},
)
)
return plan
@kernel_function(
name=ToolName.EXECUTE_PLAN_TOOL.value,
description="Given the generated plan by the model, use that plan to generate which functions to execute.",
)
async def execute_plan(
self, plan: str
) -> tuple[ToolValidationResult, list[tuple[str, dict]], list[tuple[str, dict]]]:
"""Given the generated plan by the model, use that plan to generate which functions to execute.
Once the tool calls are generated by the model, we sort them into two groups: regular plugins and terminal plugins
according to the definition in __init__
Args:
plan (str): The plan generated by the model.
Returns:
tuple[ToolValidationResult, list[tuple[str, dict]], list[tuple[str, dict]]]: A tuple containing the validation result
of the tool calls, the regular plugins to execute, and the terminal plugins to execute alongside their arguments.
"""
self.logger.info("Executing plan.")
req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
functions = self.plugins_order + self.terminal_plugins_order
result = await execution(
kernel=self.kernel,
reasoning=plan,
filter=functions,
req_settings=req_settings,
artifact_schema=self.artifact.get_schema_for_prompt(),
)
parsed_result = parse_function_result(result)
formatted_tools = format_kernel_functions_as_tools(self.kernel, functions)
validation_result = validate_tool_calling(parsed_result, formatted_tools)
# Sort plugin calls into two groups in the order of the corresponding lists defined in __init__
plugins = []
terminal_plugins = []
if validation_result == ToolValidationResult.SUCCESS:
for plugin in self.plugins_order:
for idx, called_plugin_name in enumerate(parsed_result["tool_names"]):
plugin_name = f"{plugin}-{plugin}"
if called_plugin_name == plugin_name:
plugins.append((parsed_result["tool_names"][idx], parsed_result["tool_args_list"][idx]))
for terminal_plugin in self.terminal_plugins_order:
for idx, called_plugin_name in enumerate(parsed_result["tool_names"]):
terminal_plugin_name = f"{terminal_plugin}-{terminal_plugin}"
if called_plugin_name == terminal_plugin_name:
terminal_plugins.append(
(parsed_result["tool_names"][idx], parsed_result["tool_args_list"][idx])
)
return validation_result, plugins, terminal_plugins
@kernel_function(
name=ToolName.FINAL_UPDATE_TOOL.value,
description="After the last message of a conversation was added to the conversation history, perform a final update of the artifact",
)
async def final_update(self):
"""Explicit final update of the artifact after the conversation ends."""
self.logger.info("Final update of the artifact prior to terminating the conversation.")
# Get a plan from the model
reasoning_response = await final_update_plan_function(
kernel=self.kernel,
req_settings=self.req_settings,
chat_history=self.conversation,
context=self.context,
artifact_schema=self.artifact.get_schema_for_prompt(),
artifact_state=self.artifact.get_artifact_for_prompt(),
)
# Then generate the functions to be executed
req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
functions = [ToolName.UPDATE_ARTIFACT_TOOL.value]
execution_response = await execution(
kernel=self.kernel,
reasoning=reasoning_response.value[0].content,
filter=functions,
req_settings=req_settings,
artifact_schema=self.artifact.get_schema_for_prompt(),
)
parsed_result = parse_function_result(execution_response)
formatted_tools = format_kernel_functions_as_tools(self.kernel, functions)
validation_result = validate_tool_calling(parsed_result, formatted_tools)
# If the tool call was successful, update the artifact.
if validation_result != ToolValidationResult.SUCCESS:
self.logger.warning(f"No artifact change during final update due to: {validation_result.value}")
pass
else:
for i in range(len(parsed_result["tool_names"])):
tool_name = parsed_result["tool_names"][i]
tool_args = parsed_result["tool_args_list"][i]
if (
tool_name == f"{ToolName.UPDATE_ARTIFACT_TOOL.value}-{ToolName.UPDATE_ARTIFACT_TOOL.value}"
and "field" in tool_args
and "value" in tool_args
):
# Check if tool_args contains the field and value to update
plugin_output = await self.artifact.update_artifact(
field_name=tool_args["field_name"],
field_value=tool_args["field_value"],
conversation=self.conversation,
)
if plugin_output.update_successful:
self.logger.info(f"Artifact field {tool_args['field_name']} successfully updated.")
# Set turn numbers
for message in plugin_output.messages:
message.turn_number = self.resource.turn_number
self.conversation.add_messages(plugin_output.messages)
else:
self.logger.error(f"Final artifact field update of {tool_args['field_name']} failed.")
def to_json(self) -> dict:
return {
"artifact": self.artifact.to_json(),
"agenda": self.agenda.to_json(),
"chat_history": self.conversation.to_json(),
"resource": self.resource.to_json(),
}
async def _call_plugin(self, plugin_function: Callable, plugin_args: dict):
"""Common logic whenever any plugin is called like handling errors and appending to chat history."""
self.logger.info(f"Calling plugin {plugin_function.__name__}.")
output: PluginOutput = await plugin_function(**plugin_args)
if output.update_successful:
# Set turn numbers
for message in output.messages:
message.metadata["turn_number"] = self.resource.turn_number
self.conversation.add_messages(output.messages)
else:
self.logger.warning(
f"Plugin {plugin_function.__name__} failed to execute on attempt {self.current_failed_decision_attempts} out of {MAX_DECISION_RETRIES}."
)
self.current_failed_decision_attempts += 1
@classmethod
def from_json(
cls,
json_data: dict,
kernel: Kernel,
service_id: str = "gc_main",
) -> "GuidedConversation":
artifact = Artifact.from_json(
json_data["artifact"],
kernel=kernel,
service_id=service_id,
input_artifact=cls.artifact,
max_artifact_field_retries=MAX_DECISION_RETRIES,
)
agenda = Agenda.from_json(
json_data["agenda"],
kernel=kernel,
service_id=service_id,
resource_constraint_mode=cls.resource_constraint.mode,
)
chat_history = Conversation.from_json(json_data["chat_history"])
resource = GCResource.from_json(json_data["resource"])
gc = cls(kernel, artifact, agenda, chat_history, resource, service_id)
return gc
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,42 @@
# Copyright (c) Microsoft. All rights reserved.
import ast
from types import NoneType
from typing import get_args
from pydantic import BaseModel, ValidationInfo, field_validator
class BaseModelLLM(BaseModel):
"""A Pydantic base class for use when an LLM is completing fields. Provides a custom field validator and Pydantic Config."""
@field_validator("*", mode="before")
def parse_literal_eval(cls, value: str, info: ValidationInfo): # noqa: N805
"""An LLM will always result in a string (e.g. '["x", "y"]'), so we need to parse it to the correct type"""
# Get the type hints for the field
annotation = cls.model_fields[info.field_name].annotation
typehints = get_args(annotation)
if len(typehints) == 0:
typehints = [annotation]
# Usually fields that are NoneType have another type hint as well, e.g. str | None
# if the LLM returns "None" and the field allows NoneType, we should return None
# without this code, the next if-block would leave the string "None" as the value
if (NoneType in typehints) and (value == "None"):
return None
# If the field allows strings, we don't parse it - otherwise a validation error might be raised
# e.g. phone_number = "1234567890" should not be converted to an int if the type hint is str
if str in typehints:
return value
try:
evaluated_value = ast.literal_eval(value)
return evaluated_value
except Exception:
return value
class Config:
# Ensure that validation happens every time a field is updated, not just when the artifact is created
validate_assignment = True
# Do not allow extra fields to be added to the artifact
extra = "forbid"
@@ -0,0 +1,168 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass, field
import datetime
from enum import Enum
import logging
from typing import Union
from semantic_kernel.contents import ChatMessageContent
class ConversationMessageType(Enum):
DEFAULT = "default"
ARTIFACT_UPDATE = "artifact-update"
REASONING = "reasoning"
@dataclass
class Conversation:
"""An abstraction to represent a list of messages and common operations such as adding messages
and getting a string representation.
Args:
conversation_messages (list[ChatMessageContent]): A list of ChatMessageContent objects.
"""
logger = logging.getLogger(__name__)
conversation_messages: list[ChatMessageContent] = field(default_factory=list)
def add_messages(self, messages: Union[ChatMessageContent, list[ChatMessageContent], "Conversation", None]) -> None:
"""Add a message, list of messages to the conversation or merge another conversation into the end of this one.
Args:
messages (Union[ChatMessageContent, list[ChatMessageContent], "Conversation"]): The message(s) to add.
All messages will be added to the end of the conversation.
Returns:
None
"""
if isinstance(messages, list):
self.conversation_messages.extend(messages)
elif isinstance(messages, Conversation):
self.conversation_messages.extend(messages.conversation_messages)
elif isinstance(messages, ChatMessageContent):
# if ChatMessageContent.metadata doesn't have type, then add default
if "type" not in messages.metadata:
messages.metadata["type"] = ConversationMessageType.DEFAULT
self.conversation_messages.append(messages)
else:
self.logger.warning(f"Invalid message type: {type(messages)}")
return None
def get_repr_for_prompt(
self,
exclude_types: list[ConversationMessageType] | None = None,
) -> str:
"""Create a string representation of the conversation history for use in LLM prompts.
Args:
exclude_types (list[ConversationMessageType] | None): A list of message types to exclude from the conversation
history. If None, all message types will be included.
Returns:
str: A string representation of the conversation history.
"""
if len(self.conversation_messages) == 0:
return "None"
# Do not include the excluded messages types in the conversation history repr.
if exclude_types is not None:
conversation_messages = [
message
for message in self.conversation_messages
if "type" in message.metadata and message.metadata["type"] not in exclude_types
]
else:
conversation_messages = self.conversation_messages
to_join = []
current_turn = None
for message in conversation_messages:
participant_name = message.name
# Modify the default user to be capitalized for consistency with how assistant is written.
if participant_name == "user":
participant_name = "User"
# If the turn number is None, don't include it in the string
if "turn_number" in message.metadata and current_turn != message.metadata["turn_number"]:
current_turn = message.metadata["turn_number"]
to_join.append(f"[Turn {current_turn}]")
# Add the message content
if (message.role == "assistant") and (
"type" in message.metadata and message.metadata["type"] == ConversationMessageType.ARTIFACT_UPDATE
):
to_join.append(message.content)
elif message.role == "assistant":
to_join.append(f"Assistant: {message.content}")
else:
user_string = message.content.strip()
if user_string == "":
to_join.append(f"{participant_name}: <sent an empty message>")
else:
to_join.append(f"{participant_name}: {user_string}")
conversation_string = "\n".join(to_join)
return conversation_string
def set_turn_numbers(self, turn_number: int) -> None:
"""Set all the turn numbers in the conversation to the given turn number.
Args:
turn_number (int): The turn number to set for all messages.
Returns:
None"""
for message in self.conversation_messages:
message.metadata["turn_number"] = turn_number
@staticmethod
def message_to_json(message: ChatMessageContent) -> dict:
"""
Convert a ChatMessageContent object to a JSON serializable dictionary.
Args:
message (ChatMessageContent): The ChatMessageContent object to convert to JSON.
Returns:
dict: A JSON serializable dictionary representation of the ChatMessageContent object.
"""
return {
"role": message.role,
"content": message.content,
"name": message.name,
"metadata": {
"turn_number": message.metadata.get("turn_number", None),
"type": message.metadata.get("type", ConversationMessageType.DEFAULT),
},
}
def to_json(self) -> dict:
json_data = {}
json_data["conversation"] = {}
json_data["conversation"]["conversation_messages"] = [
self.message_to_json(message) for message in self.conversation_messages
]
return json_data
@classmethod
def from_json(
cls,
json_data: dict,
) -> "Conversation":
conversation = cls()
for message in json_data["conversation"]["conversation_messages"]:
conversation.add_messages(
ChatMessageContent(
role=message["role"],
content=message["content"],
name=message["name"],
metadata={
"turn_number": message["turn_number"],
"type": ConversationMessageType(message["type"]),
"timestamp": datetime.datetime.fromisoformat(message["timestamp"]),
},
)
)
return conversation
@@ -0,0 +1,158 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from enum import Enum
import json
import logging
from typing import Any
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.functions import FunctionResult
logger = logging.getLogger(__name__)
@dataclass
class ToolArg:
argument_name: str
required: bool
@dataclass
class Tool:
name: str
args: list[ToolArg]
class ToolValidationResult(Enum):
NO_TOOL_CALLED = "No tool was called"
INVALID_TOOL_CALLED = "A tool was called with an unexpected name"
MISSING_REQUIRED_ARGUMENT = "The tool called is missing a required argument"
INVALID_ARGUMENT_TYPE = "The value of an argument is of an unexpected type"
SUCCESS = "success"
def parse_function_result(response: FunctionResult) -> dict[str, Any]:
"""Parse the response from SK's FunctionResult object into only the relevant data for easier downstream processing.
This should only be used when you expect the response to contain tool calls.
Args:
response (FunctionResult): The response from the kernel.
Returns:
dict[str, Any]: The parsed response data with the following format if n was set greater than 1:
{
"choices": [
{
"tool_names": list[str],
"tool_args_list": list[dict[str, Any]],
"message": str,
"finish_reason": str,
"validation_result": ToolValidationResult
}, ...
]
}
Otherwise, the response will directly contain the data from the first choice (tool_names, etc keys)
"""
response_data: dict[str, Any] = {"choices": []}
for response_choice in response.value:
response_data_curr = {}
finish_reason = response_choice.finish_reason
if finish_reason == "tool_calls":
tool_names = []
tool_args_list = []
# Only look at the items that are of instance `FunctionCallContent`
tool_calls = [item for item in response_choice.items if isinstance(item, FunctionCallContent)]
for tool_call in tool_calls:
if "-" not in tool_call.name:
logger.info(f"Tool call name {tool_call.name} does not match naming convention - modifying name.")
tool_names.append(tool_call.name + "-" + tool_call.name)
else:
tool_names.append(tool_call.name)
try:
tool_args = json.loads(tool_call.arguments)
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments for tool call {tool_call.name}. Using empty dict.")
tool_args = {}
tool_args_list.append(tool_args)
response_data_curr["tool_names"] = tool_names
response_data_curr["tool_args_list"] = tool_args_list
response_data_curr["message"] = response_choice.content
response_data_curr["finish_reason"] = finish_reason
response_data["choices"].append(response_data_curr)
if len(response_data["choices"]) == 1:
response_data.update(response_data["choices"][0])
del response_data["choices"]
return response_data
def construct_tool_objects(kernel_function_tools: dict) -> list[Tool]:
"""Construct a list of Tool objects from the kernel function tools definition.
Args:
kernel_function_tools (dict): The definition of tools done by the kernel function.
Returns:
list[Tool]: The list of Tool objects constructed from the kernel function tools definition.
"""
tool_objects: list[Tool] = []
for tool_definition in kernel_function_tools:
tool_name = tool_definition["function"]["name"]
tool_args = tool_definition["function"]["parameters"]["properties"]
tool_arg_objects: list[ToolArg] = []
for argument_name, _ in tool_args.items():
tool_arg = ToolArg(argument_name=argument_name, required=False)
tool_arg_objects.append(tool_arg)
required_args = tool_definition["function"]["parameters"]["required"]
for tool_arg_object in tool_arg_objects:
if tool_arg_object.argument_name in required_args:
tool_arg_object.required = True
tool_objects.append(Tool(name=tool_name, args=tool_arg_objects))
return tool_objects
def validate_tool_calling(response: dict[str, Any], request_tool_param: dict) -> ToolValidationResult:
"""Validate that the response from the LLM called tools corrected.
1. Check if any tool was called.
2. Check if the tools called were valid (names match)
3. Check if all the required arguments were passed.
Args:
response (dict[str, Any]): The response from the LLM containing the tools called (output of parse_function_result)
tools (list[Tool]): The list of tools that can be called by the model.
Returns:
ToolValidationResult: The result of the validation. ToolValidationResult.SUCCESS if the validation passed.
"""
tool_objects = construct_tool_objects(request_tool_param)
tool_names = response.get("tool_names", [])
tool_args_list = response.get("tool_args_list", [])
# Check if any tool was called.
if not tool_names:
logger.info("No tool was called.")
return ToolValidationResult.NO_TOOL_CALLED
for tool_name, tool_args in zip(tool_names, tool_args_list, strict=True):
# Check the tool names is valid.
tool: Tool | None = next((t for t in tool_objects if t.name == tool_name), None)
if not tool:
logger.warning(f"Invalid tool called: {tool_name}")
return ToolValidationResult.INVALID_TOOL_CALLED
for arg in tool.args:
# Check if the required arguments were passed.
if arg.required and arg.argument_name not in tool_args:
logger.warning(f"Missing required argument '{arg.argument_name}' for tool '{tool_name}'.")
return ToolValidationResult.MISSING_REQUIRED_ARGUMENT
return ToolValidationResult.SUCCESS
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from pydantic import ValidationError
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.functions import KernelArguments
from guided_conversation.utils.openai_tool_calling import parse_function_result, validate_tool_calling
@dataclass
class PluginOutput:
"""A wrapper for all Guided Conversation Plugins. This class is used to return the output of a generic plugin.
Args:
update_successful (bool): Whether the update was successful.
messages (list[ChatMessageContent]): A list of messages to be used at the user's digression, it
contains information about the process of calling the plugin.
"""
update_successful: bool
messages: list[ChatMessageContent]
def format_kernel_functions_as_tools(kernel: Kernel, functions: list[str]):
"""Format kernel functions as JSON schemas for custom validation."""
formatted_tools = []
for _, kernel_plugin_def in kernel.plugins.items():
for function_name, function_def in kernel_plugin_def.functions.items():
if function_name in functions:
func_metadata = function_def.metadata
formatted_tools.append(kernel_function_metadata_to_function_call_format(func_metadata))
return formatted_tools
async def fix_error(
kernel: Kernel, prompt_template: str, req_settings: AzureChatCompletion, arguments: KernelArguments
) -> dict:
"""Invokes the error correction plugin. If a plugin is called & fails during execution, this function will retry
the plugin. At a high level, we recommend the following steps when calling a plugin:
1. Call the plugin.
2. Parse the response.
3. Validate the response.
4. If the response is invalid (Validation or Value Error), retry the plugin by calling *this function*. For best
results, check out plugins/agenda.py or plugins/artifact.py for examples of prompt templates & corresponding
tools (which should be passed in the req_settings object). This function will handle the retry logic for you.
Args:
kernel (Kernel): The kernel object.
prompt_template (str): The prompt template for the plugin.
req_settings (AzureChatCompletion): The prompt execution settings.
arguments (KernelArguments): The kernel arguments.
Returns:
dict: The result of the plugin call.
"""
kernel_function_obj = kernel.add_function(
prompt=prompt_template,
function_name="error_correction",
plugin_name="error_correction",
template_format="handlebars",
prompt_execution_settings=req_settings,
)
result = await kernel.invoke(function=kernel_function_obj, arguments=arguments)
parsed_result = parse_function_result(result)
formatted_tools = []
for _, kernel_plugin_def in kernel.plugins.items():
for _, function_def in kernel_plugin_def.functions.items():
func_metadata = function_def.metadata
formatted_tools.append(kernel_function_metadata_to_function_call_format(func_metadata))
# Add any tools from req_settings
if req_settings.tools:
formatted_tools.extend(req_settings.tools)
validation_result = validate_tool_calling(parsed_result, formatted_tools)
parsed_result["validation_result"] = validation_result
return parsed_result
def update_attempts(error: Exception, attempt_id: str, previous_attempts: list) -> str:
"""
Updates the plugin class attribute list of previous attempts with the current attempt and error message
(including duplicates).
Args:
error (Exception): The error object.
attempt_id (str): The ID of the current attempt.
previous_attempts (list): The list of previous attempts.
Returns:
str: A formatted (optimized for LLM performance) string of previous attempts, with duplicates removed.
"""
if isinstance(error, ValidationError):
error_str = "; ".join([e.get("msg") for e in error.errors()])
# replace "; Input should be 'Unanswered'" with " or input should be 'Unanswered'" for clarity
error_str = error_str.replace("; Input should be 'Unanswered'", " or input should be 'Unanswered'")
else:
error_str = str(error)
new_failed_attempt = (attempt_id, error_str)
previous_attempts.append(new_failed_attempt)
# Format previous attempts to be more friendly for the LLM
attempts_list = []
unique_attempts = set(previous_attempts)
for attempt, error in unique_attempts:
attempts_list.append(f"Attempt: {attempt}\nError: {error}")
llm_formatted_attempts = "\n".join(attempts_list)
return previous_attempts, llm_formatted_attempts
@@ -0,0 +1,251 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
import logging
import math
import time
from pydantic import BaseModel
class ResourceConstraintUnit(Enum):
"""Choose the unit of the resource constraint.
Seconds and Minutes are real-time and will be impacted by the latency of the model."""
SECONDS = "seconds"
MINUTES = "minutes"
TURNS = "turns"
class ResourceConstraintMode(Enum):
"""Choose how the agent should use the resource.
Maximum: is an upper bound, i.e. the agent can end the conversation before the resource is exhausted
Exact: the agent should aim to use exactly the given amount of the resource"""
MAXIMUM = "maximum"
EXACT = "exact"
class ResourceConstraint(BaseModel):
"""A structured representation of the resource constraint for the GuidedConversation agent.
Args:
quantity (float | int): The quantity of the resource constraint.
unit (ResourceConstraintUnit): The unit of the resource constraint.
mode (ResourceConstraintMode): The mode of the resource constraint.
"""
quantity: float | int
unit: ResourceConstraintUnit
mode: ResourceConstraintMode
class Config:
arbitrary_types_allowed = True
def format_resource(quantity: float, unit: ResourceConstraintUnit) -> str:
"""Get formatted string for a given quantity and unit (e.g. 1 second, 20 seconds)"""
if unit != ResourceConstraintUnit.TURNS:
quantity = round(quantity, 1)
unit = unit.value
return f"{quantity} {unit[:-1] if quantity == 1 else unit}"
class GCResource:
"""Resource constraints for the GuidedConversation agent. This class is used to keep track of the resource
constraints. If resource_constraint is None, then the agent can continue indefinitely. This also means
that no agenda will be created for the conversation.
Args:
resource_constraint (ResourceConstraint | None): The resource constraint for the conversation.
initial_seconds_per_turn (int): The initial number of seconds per turn. Defaults to 120 seconds.
"""
def __init__(
self,
resource_constraint: ResourceConstraint | None,
initial_seconds_per_turn: int = 120,
):
logger = logging.getLogger(__name__)
self.logger = logger
self.resource_constraint: ResourceConstraint | None = resource_constraint
self.initial_seconds_per_turn: int = initial_seconds_per_turn
self.turn_number: int = 0
self.remaining_units: float | None = None
self.elapsed_units: float | None = None
if resource_constraint is not None:
self.elapsed_units = 0
self.remaining_units = resource_constraint.quantity
def start_resource(self) -> None:
"""To be called at the start of a conversation turn"""
if self.resource_constraint is not None and (
self.resource_constraint.unit == ResourceConstraintUnit.SECONDS
or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES
):
self.start_time = time.time()
def increment_resource(self) -> None:
"""Increment the resource counter by one turn."""
if self.resource_constraint is not None:
if self.resource_constraint.unit == ResourceConstraintUnit.SECONDS:
self.elapsed_units += time.time() - self.start_time
self.remaining_units = self.resource_constraint.quantity - self.elapsed_units
elif self.resource_constraint.unit == ResourceConstraintUnit.MINUTES:
self.elapsed_units += (time.time() - self.start_time) / 60
self.remaining_units = self.resource_constraint.quantity - self.elapsed_units
elif self.resource_constraint.unit == ResourceConstraintUnit.TURNS:
self.elapsed_units += 1
self.remaining_units -= 1
self.turn_number += 1
def get_resource_mode(self) -> ResourceConstraintMode:
"""Get the mode of the resource constraint.
Returns:
ResourceConstraintMode | None: The mode of the resource constraint, or None if there is no
resource constraint.
"""
return self.resource_constraint.mode if self.resource_constraint is not None else None
def get_elapsed_turns(self, formatted_repr: bool = False) -> str | int:
"""Get the number of elapsed turns.
Args:
formatted_repr (bool): If true, return a formatted string representation of the elapsed turns.
If false, return an integer. Defaults to False.
Returns:
str | int: The description/number of elapsed turns.
"""
if formatted_repr:
return format_resource(self.turn_number, ResourceConstraintUnit.TURNS)
else:
return self.turn_number
def get_remaining_turns(self, formatted_repr: bool = False) -> str | int:
"""Get the number of remaining turns.
Args:
formatted_repr (bool): If true, return a formatted string representation of the remaining turns.
Returns:
str | int: The description/number of remaining turns.
"""
if formatted_repr:
return format_resource(self.estimate_remaining_turns(), ResourceConstraintUnit.TURNS)
else:
return self.estimate_remaining_turns()
def estimate_remaining_turns(self) -> int:
"""Estimate the remaining turns based on the resource constraint, thereby translating certain
resource units (e.g. seconds, minutes) into turns.
Returns:
int: The estimated number of remaining turns.
"""
if self.resource_constraint is not None:
if (
self.resource_constraint.unit == ResourceConstraintUnit.SECONDS
or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES
):
elapsed_turns = self.turn_number
# TODO: This can likely be simplified
if self.resource_constraint.unit == ResourceConstraintUnit.MINUTES:
time_per_turn = (
self.initial_seconds_per_turn
if elapsed_turns == 0
else (self.elapsed_units * 60) / elapsed_turns
)
time_per_turn /= 60
else:
time_per_turn = (
self.initial_seconds_per_turn if elapsed_turns == 0 else self.elapsed_units / elapsed_turns
)
remaining_turns = self.remaining_units / time_per_turn
# Round down, unless it's less than 1, in which case round up
remaining_turns = math.ceil(remaining_turns) if remaining_turns < 1 else math.floor(remaining_turns)
return remaining_turns
elif self.resource_constraint.unit == ResourceConstraintUnit.TURNS:
return self.resource_constraint.quantity - self.turn_number
else:
self.logger.error(
"Resource constraint is not set, so turns cannot be estimated using function estimate_remaining_turns"
)
raise ValueError(
"Resource constraint is not set. Do not try to call this method without a resource constraint."
)
def get_resource_instructions(self) -> tuple[str, str]:
"""Get the resource instructions for the conversation.
Assumes we're always using turns as the resource unit.
Returns:
str: the resource instructions
"""
if self.resource_constraint is None:
return ""
formatted_elapsed_resource = format_resource(self.elapsed_units, ResourceConstraintUnit.TURNS)
formatted_remaining_resource = format_resource(self.remaining_units, ResourceConstraintUnit.TURNS)
# if the resource quantity is anything other than 1, the resource unit should be plural (e.g. "minutes" instead of "minute")
is_plural_elapsed = self.elapsed_units != 1
is_plural_remaining = self.remaining_units != 1
if self.elapsed_units > 0:
resource_instructions = f"So far, {formatted_elapsed_resource} {'have' if is_plural_elapsed else 'has'} elapsed since the conversation began. "
else:
resource_instructions = ""
if self.resource_constraint.mode == ResourceConstraintMode.EXACT:
exact_mode_instructions = f"""There {"are" if is_plural_remaining else "is"} {formatted_remaining_resource} remaining (including this one) - the conversation will automatically terminate when 0 turns are left. \
You should continue the conversation until it is automatically terminated. This means you should NOT preemptively end the conversation, \
either explicitly (by selecting the "End conversation" action) or implicitly (e.g. by telling the user that you have all required information and they should wait for the next step). \
Your goal is not to maximize efficiency (i.e. complete the artifact as quickly as possible then end the conversation), but rather to make the best use of ALL remaining turns available to you"""
if is_plural_remaining:
resource_instructions += f"""{exact_mode_instructions}. This will require you to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier, \
or where you've rushed through the conversation and all fields are completed but there are still many turns left."""
# special instruction for the final turn (i.e. 1 remaining) in exact mode
else:
resource_instructions += f"""{exact_mode_instructions}, including this one. Therefore, you should use this turn to ask for any remaining information needed to complete the artifact, \
or, if the artifact is already completed, continue to broaden/deepen the discussion in a way that's directly relevant to the artifact. Do NOT indicate to the user that the conversation is ending."""
elif self.resource_constraint.mode == ResourceConstraintMode.MAXIMUM:
resource_instructions += f"""You have a maximum of {formatted_remaining_resource} (including this one) left to complete the conversation. \
You can decide to terminate the conversation at any point (including now), otherwise the conversation will automatically terminate when 0 turns are left. \
You will need to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier."""
else:
self.logger.error("Invalid resource mode provided.")
return resource_instructions
def to_json(self) -> dict:
return {
"turn_number": self.turn_number,
"remaining_units": self.remaining_units,
"elapsed_units": self.elapsed_units,
}
@classmethod
def from_json(
cls,
json_data: dict,
) -> "GCResource":
gc_resource = cls(
resource_constraint=None,
initial_seconds_per_turn=120,
)
gc_resource.turn_number = json_data["turn_number"]
gc_resource.remaining_units = json_data["remaining_units"]
gc_resource.elapsed_units = json_data["elapsed_units"]
return gc_resource
@@ -0,0 +1,127 @@
# Copyright (c) Microsoft. All rights reserved.
"""Run this interactive guided conversation script to test out the teaching scenario!
The teaching artifact, rules, conversation flow, context, and resource constraint can all be modified to
fit your needs & try out new scenarios!
"""
import asyncio
from azure.identity import AzureCliCredential
from pydantic import BaseModel, Field
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from guided_conversation.plugins.guided_conversation_agent import GuidedConversation
from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit
# Artifact - The artifact is like a form that the agent must complete throughout the conversation.
# It can also be thought of as a working memory for the agent.
# We allow any valid Pydantic BaseModel class to be used.
class MyArtifact(BaseModel):
student_poem: str = Field(description="The acrostic poem written by the student.")
initial_feedback: str = Field(description="Feedback on the student's final revised poem.")
final_feedback: str = Field(description="Feedback on how the student was able to improve their poem.")
inappropriate_behavior: list[str] = Field(
description="""List any inappropriate behavior the student attempted while chatting with you. \
It is ok to leave this field Unanswered if there was none."""
)
# Rules - These are the do's and don'ts that the agent should follow during the conversation.
rules = [
"DO NOT write the poem for the student."
"Terminate the conversation immediately if the students asks for harmful or inappropriate content.",
]
# Conversation Flow (optional) - This defines in natural language the steps of the conversation.
conversation_flow = """1. Start by explaining interactively what an acrostic poem is.
2. Then give the following instructions for how to go ahead and write one:
1. Choose a word or phrase that will be the subject of your acrostic poem.
2. Write the letters of your chosen word or phrase vertically down the page.
3. Think of a word or phrase that starts with each letter of your chosen word or phrase.
4. Write these words or phrases next to the corresponding letters to create your acrostic poem.
3. Then give the following example of a poem where the word or phrase is HAPPY:
Having fun with friends all day,
Awesome games that we all play.
Pizza parties on the weekend,
Puppies we bend down to tend,
Yelling yay when we win the game
4. Finally have the student write their own acrostic poem using the word or phrase of their choice. Encourage them to be creative and have fun with it.
After they write it, you should review it and give them feedback on what they did well and what they could improve on.
Have them revise their poem based on your feedback and then review it again.
"""
# Context (optional) - This is any additional information or the circumstances the agent is in that it should be aware of.
# It can also include the high level goal of the conversation if needed.
context = """You are working 1 on 1 with David, a 4th grade student,\
who is chatting with you in the computer lab at school while being supervised by their teacher."""
# Resource Constraints (optional) - This defines the constraints on the conversation such as time or turns.
# It can also help with pacing the conversation,
# For example, here we have set an exact time limit of 10 turns which the agent will try to fill.
resource_constraint = ResourceConstraint(
quantity=10,
unit=ResourceConstraintUnit.TURNS,
mode=ResourceConstraintMode.EXACT,
)
async def main() -> None:
"""Main function to interactively run a guided conversation.
The user can chat with this teaching agent until:
1. The user types 'exit' to end the conversation.
2. There's a KeyboardInterrupt or EOFError.
3. The conversation ends. This can be due to the agent ending the conversation, which will happen if the resource constraint is met, the artifact is complete, or the conversation just isn't making progress (user is not cooperative).
"""
kernel = Kernel()
service_id = "gc_main"
chat_service = AzureChatCompletion(
service_id=service_id,
deployment_name="gpt-4o-2024-05-13",
api_version="2024-05-01-preview",
credential=AzureCliCredential(),
)
kernel.add_service(chat_service)
guided_conversation_agent = GuidedConversation(
kernel=kernel,
artifact=MyArtifact,
conversation_flow=conversation_flow,
context=context,
rules=rules,
resource_constraint=resource_constraint,
service_id=service_id,
)
# Step the conversation to start the conversation with the agent
result = await guided_conversation_agent.step_conversation()
print(f"Assistant: {result.ai_message}")
while True:
try:
# Get user input
user_input = input("User: ")
except KeyboardInterrupt:
print("\n\nExiting chat...")
return
except EOFError:
print("\n\nExiting chat...")
return
if user_input == "exit":
print("\n\nExiting chat...")
return
else:
# Step the conversation to get the agent's reply
result = await guided_conversation_agent.step_conversation(user_input=user_input)
print(f"Assistant: {result.ai_message}")
if result.is_conversation_over:
return
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,653 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Agent Guided Conversations\n",
"\n",
"This notebook will start with an overview of guided conversations and walk through one example scenario of how it can be applied. Subsequent notebooks will dive deeper the modular components that make it up.\n",
"\n",
"## Motivating Example - Education\n",
"\n",
"We focus on an elementary education scenario. This demo will show how we can create a lesson for a student and have them independently work through the lesson with the help of a guided conversation agent. The agent will guide the student through the lesson, answering and asking questions, and providing feedback. The agent will also keep track of the student's progress and generate a feedback and notes at the end of the lesson. We highlight how the agent is able to follow a conversation flow, whilst still being able to exercise judgement to answer and keeping the conversation on track over multiple turns. Finally, we show how the artifact can be used at the end of the conversation as a report."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Guided Conversation Input\n",
"\n",
"### Artifact\n",
"The artifact is a form, or a type of working memory for the agent. We implement it using a Pydantic BaseModel. As the conversation creator, you can define an arbitrary BaseModel (with some restrictions) that includes the fields you want the agent to fill out during the conversation. \n",
"\n",
"### Rules\n",
"Rules is a list of *do's and don'ts* that the agent should attempt to follow during the conversation. \n",
"\n",
"### Conversation Flow (optional)\n",
"Conversation flow is a loose natural language description of the steps of the conversation. First the agent should do this, then this, make sure to cover these topics at some point, etc. \n",
"This field is optional as the artifact could be treated as a conversation flow.\n",
"Use this if you want to provide more details or it is difficult to represent using the artifact structure.\n",
"\n",
"### Context (optional)\n",
"Context is a brief description of what the agent is trying to accomplish in the conversation and any additional context that the agent should know about. \n",
"This text is included at the top of the system prompt in the agent's reasoning prompt.\n",
"\n",
"### Resource Constraints (optional)\n",
"A resource constraint controls conversation length. It consists of two key elements:\n",
"- **Unit** defines the measurement of length. We have implemented seconds, minutes, and turns. An extension could be around cost, such as tokens generated.\n",
"- **Mode** determines how the constraint is applied. Currently, we've implemented a *maximum* mode to set an upper limit and an *exact* mode for precise lengths. Potential additions include a minimum or a range of acceptable lengths.\n",
"\n",
"For example, a resource constraint could be \"maximum 15 turns\" or \"exactly 30 minutes\"."
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"from pydantic import BaseModel, Field\n",
"\n",
"from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit\n",
"\n",
"\n",
"class StudentFeedbackArtifact(BaseModel):\n",
" student_poem: str = Field(description=\"The latest acrostic poem written by the student.\")\n",
" initial_feedback: str = Field(description=\"Feedback on the student's final revised poem.\")\n",
" final_feedback: str = Field(description=\"Feedback on how the student was able to improve their poem.\")\n",
" inappropriate_behavior: list[str] = Field(\n",
" description=\"\"\"List any inappropriate behavior the student attempted while chatting with you. \\\n",
"It is ok to leave this field Unanswered if there was none.\"\"\"\n",
" )\n",
"\n",
"\n",
"rules = [\n",
" \"DO NOT write the poem for the student.\",\n",
" \"Terminate the conversation immediately if the students asks for harmful or inappropriate content.\",\n",
" \"Do not counsel the student.\",\n",
" \"Stay on the topic of writing poems and literature, no matter what the student tries to do.\",\n",
"]\n",
"\n",
"\n",
"conversation_flow = \"\"\"1. Start by explaining interactively what an acrostic poem is.\n",
"2. Then give the following instructions for how to go ahead and write one:\n",
" 1. Choose a word or phrase that will be the subject of your acrostic poem.\n",
" 2. Write the letters of your chosen word or phrase vertically down the page.\n",
" 3. Think of a word or phrase that starts with each letter of your chosen word or phrase.\n",
" 4. Write these words or phrases next to the corresponding letters to create your acrostic poem.\n",
"3. Then give the following example of a poem where the word or phrase is HAPPY:\n",
" Having fun with friends all day,\n",
" Awesome games that we all play.\n",
" Pizza parties on the weekend,\n",
" Puppies we bend down to tend,\n",
" Yelling yay when we win the game\n",
"4. Finally have the student write their own acrostic poem using the word or phrase of their choice. Encourage them to be creative and have fun with it.\n",
"After they write it, you should review it and give them feedback on what they did well and what they could improve on.\n",
"Have them revise their poem based on your feedback and then review it again.\"\"\"\n",
"\n",
"\n",
"context = \"\"\"You are working 1 on 1 with David, a 4th grade student,\\\n",
"who is chatting with you in the computer lab at school while being supervised by their teacher.\"\"\"\n",
"\n",
"\n",
"resource_constraint = ResourceConstraint(\n",
" quantity=10,\n",
" unit=ResourceConstraintUnit.TURNS,\n",
" mode=ResourceConstraintMode.EXACT,\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Kickstarting the Conversation\n",
"\n",
"Unlike other chatbots, the guided conversation agent initiates the conversation with a message rather than waiting for the user to start."
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Hello David! Today we are going to write an acrostic poem. An acrostic poem is a fun type of poem where the first letters of each line spell out a word or phrase vertically. Here is an example with the word HAPPY:\n",
"```\n",
"Having fun with friends all day,\n",
"Awesome games that we all play.\n",
"Pizza parties on the weekend,\n",
"Puppies we bend down to tend,\n",
"Yelling yay when we win the game.\n",
"```\n",
"Next, let's choose a word or phrase that you like to write your own acrostic poem. It can be anything you find interesting or fun!\n"
]
}
],
"source": [
"from semantic_kernel import Kernel\n",
"from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion\n",
"\n",
"from guided_conversation.plugins.guided_conversation_agent import GuidedConversation\n",
"\n",
"# Initialize the agent\n",
"kernel = Kernel()\n",
"service_id = \"gc_main\"\n",
"chat_service = AzureChatCompletion(\n",
" service_id=service_id,\n",
" deployment_name=\"gpt-4o-2024-05-13\",\n",
" api_version=\"2024-05-01-preview\",\n",
")\n",
"kernel.add_service(chat_service)\n",
"guided_conversation_agent = GuidedConversation(\n",
" kernel=kernel,\n",
" artifact=StudentFeedbackArtifact,\n",
" conversation_flow=conversation_flow,\n",
" context=context,\n",
" rules=rules,\n",
" resource_constraint=resource_constraint,\n",
" service_id=service_id,\n",
")\n",
"\n",
"# Kickstart the conversation by calling step_conversation without any input to get the first message for the user.\n",
"response = await guided_conversation_agent.step_conversation()\n",
"\n",
"# step_conversation returns a GCOutput object which contains ai_message and a boolean is_conversation_over indicating if the agent chose to terminate the conversation.\n",
"# This object could be extended to include more information if desired.\n",
"print(response.ai_message)"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [],
"source": [
"# Any helper functions go here.\n",
"\n",
"from guided_conversation.utils.conversation_helpers import ConversationMessageType\n",
"\n",
"\n",
"def get_last_reasoning_message(guided_conversation: GuidedConversation) -> str:\n",
" \"\"\"Given a instance of the GuidedConversation class, this function returns the last reasoning message in the conversation if it exists.\"\"\"\n",
" messages = guided_conversation.conversation.conversation_messages\n",
" msg = \"No previous reasoning message found.\"\n",
" for message in reversed(messages):\n",
" if message.metadata[\"type\"] == ConversationMessageType.REASONING:\n",
" msg = message.content\n",
" break\n",
" return msg"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Let's now reply as the student to the agent's message and see what happens. This is the typical flow of a guided conversation. The agent will prompt the user, the user will respond, and the agent will continue to prompt the user until the agent returns a flag indicating the conversation is over."
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Great choice, David! Let's write 'SUMMER' vertically on your page. Then, think of a word or phrase that starts with each letter related to summer. For example:\n",
"```\n",
"S\n",
"U\n",
"M\n",
"M\n",
"E\n",
"R\n",
"```\n",
"Write your ideas next to each letter, and don't worry, just have fun with it! Share with me what you've written, and we'll take it from there.\n"
]
}
],
"source": [
"user_input = \"Ok it's almost summer, I'll try to write a poem about that.\"\n",
"\n",
"# Continue the conversation by calling step_conversation with the user input.\n",
"response = await guided_conversation_agent.step_conversation(user_input)\n",
"\n",
"print(response.ai_message)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### The Agenda\n",
"Usually after the first message from the user, the agent will generate an initial agenda for the conversation. \n",
"Let's examine what it currently looks like. Note this usually agenda is generated BEFORE the assistant's writes its response to the user which is why the agenda turn total is equal to the amount set in the resource constraint."
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Current agenda:\n",
"1. [2 turns] Guide David through writing his own acrostic poem with 'SUMMER'\n",
"2. [2 turns] Review David's poem and give initial feedback\n",
"3. [3 turns] Have David revise his poem based on the feedback\n",
"4. [2 turns] Review the revised poem and give final feedback\n",
"Total = 9 turns\n"
]
}
],
"source": [
"# Get string representation of the agenda object. We use this same function for formatting into reasoning prompts.\n",
"print(\"Current agenda:\\n\" + guided_conversation_agent.agenda.get_agenda_for_prompt())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now let's give the agent and incomplete poem."
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"You're doing great, David! Now let's think of lines for the remaining letters 'M,' 'E,' and 'R.' Remember to keep it related to summer. Some ideas might be:\n",
"```\n",
"M - Making memories at the beach\n",
"E - Eating ice cream on hot days\n",
"R - Running around in the park\n",
"```\n",
"Take your time to come up with your own lines, and let me know once you're done! If you need any suggestions or help, I'm here to assist.\n"
]
}
],
"source": [
"user_input = \"\"\"Here is my poem so far.\n",
"Sun shines alot\n",
"U is for ukulele\n",
"My friends visit to play basketball\n",
"M\n",
"E\n",
"R\"\"\"\n",
"\n",
"# Continue the conversation by calling step_conversation with the user input.\n",
"response = await guided_conversation_agent.step_conversation(user_input)\n",
"print(response.ai_message)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The agent tries to guide us to keep writing the poem! \n",
"\n",
"Let's try to push our luck and have the agent write the rest for us. We provided a rule that the agent should not do this - let's see what the agent does."
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"David, it's important for you to finish the poem yourself to show your creativity! Let's continue with your great start. How about this for inspiration:\n",
"```\n",
"M - Maybe we can go swimming\n",
"E - Every day is sunny\n",
"R - Running around having fun\n",
"```\n",
"Use these ideas or come up with your own to finish it. You're doing an awesome job! Once you have your lines, share them with me and we'll go from there.\n"
]
}
],
"source": [
"user_input = \"\"\"I got pretty far can you write the rest for me?\"\"\"\n",
"\n",
"response = await guided_conversation_agent.step_conversation(user_input)\n",
"print(response.ai_message)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Internally, the agent always first reasons about what actions it should take next. Let's see what the agent's reasoning was for this turn. This can often help us understand where the agent went wrong.\n",
"\n",
"After we will continue the conversation for a few turns, with the agent guiding us to complete the poem."
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"### Reasoning:\n",
"\n",
"1. **Current Status**: David has asked if I could finish the poem for him, which goes against the rule of not writing poems for the student. We'll need to address this by encouraging him to complete it himself.\n",
"\n",
"2. **Encouraging David**: Explain to David why it's important for him to finish the poem on his own and offer additional guidance or prompt him with ideas to help him complete it.\n",
"\n",
"3. **Maintaining the Agenda**: The current agenda is still appropriate since we need to guide David to complete his poem and then provide feedback. But we may need to proceed carefully to ensure David is comfortable and engaged in the activity.\n",
"\n",
"### Action Plan:\n",
"\n",
"1. **Update agenda**:\n",
" - **Items**:\n",
" 1. Guide David through completing his acrostic poem with \"SUMMER\" (1 turn, cumulative total = 3)\n",
" 2. Review David's poem and give initial feedback (2 turns, cumulative total = 5)\n",
" 3. Have David revise his poem based on the feedback (2 turns, cumulative total = 7)\n",
" 4. Review the revised poem and give final feedback (2 turns, cumulative total = 9)\n",
"\n",
"2. **Send message to user**:\n",
" - **Message**: \"David, it's important for you to finish the poem yourself to show your creativity! Let's continue with your great start. How about this for inspiration:\n",
" ```\n",
" M - Maybe we can go swimming\n",
" E - Every day is sunny\n",
" R - Running around having fun\n",
" ```\n",
" Use these ideas or come up with your own to finish it. You're doing an awesome job! Once you have your lines, share them with me and we'll go from there.\"\n",
"\n",
"### Final Recommendation:\n",
"\n",
"#### Actions:\n",
"1. **Update agenda**:\n",
" - **Items**:\n",
" 1. Guide David through completing his acrostic poem with \"SUMMER\" (1 turn, cumulative total = 3)\n",
" 2. Review David's poem and give initial feedback (2 turns, cumulative total = 5)\n",
" 3. Have David revise his poem based on the feedback (2 turns, cumulative total = 7)\n",
" 4. Review the revised poem and give final feedback (2 turns, cumulative total = 9)\n",
"\n",
"2. **Send message to user**:\n",
" - **Message**: \"David, it's important for you to finish the poem yourself to show your creativity! Let's continue with your great start. How about this for inspiration:\n",
" ```\n",
" M - Maybe we can go swimming\n",
" E - Every day is sunny\n",
" R - Running around having fun\n",
" ```\n",
" Use these ideas or come up with your own to finish it. You're doing an awesome job! Once you have your lines, share them with me and we'll go from there.\"\n",
"\n"
]
}
],
"source": [
"# Get the last reasoning message.\n",
"print(get_last_reasoning_message(guided_conversation_agent))"
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Sure, David! Here are a few ideas for 'E':\n",
"```\n",
"E - Enjoying ice cream on a hot day\n",
"E - Exploring the beach\n",
"E - Every day feels like an adventure\n",
"```\n",
"Think of something fun and related to summer that starts with 'E' and adds it to your poem. Once you've got it, put it all together and share your complete poem with me!\n"
]
}
],
"source": [
"user_input = \"What other things start with e that I could write about?\"\n",
"\n",
"response = await guided_conversation_agent.step_conversation(user_input)\n",
"print(response.ai_message)"
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Great job, David! Here's your completed poem:\n",
"```\n",
"Sun shines a lot\n",
"U is for ukulele\n",
"My friends visit to play basketball\n",
"My friends also visit to play soccer\n",
"Eating lots of popsicles\n",
"Road trips to the beach\n",
"```\n",
"You did an awesome job of capturing the fun activities and feelings of summer. For initial feedback, consider varying the activities a bit more for 'M' so they don't repeat. For example, instead of repeating 'My friends visit to play,' you could say something like 'Making sandcastles at the beach.' What do you think? Would you like to try revising your 'M' lines?\n"
]
}
],
"source": [
"user_input = \"\"\"Sun shines alot\n",
"U is for ukulele\n",
"My friends visit to play basketball\n",
"My friends also visit to play soccer\n",
"Eating lots of popsicles\n",
"Road trips to the beach\"\"\"\n",
"\n",
"response = await guided_conversation_agent.step_conversation(user_input)\n",
"print(response.ai_message)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"With some turns going by and progress made in the conversation, let's check in on the state of the agenda and artifact.\n",
"\n",
"If the agent has chosen to update the agenda, we will see the updated agenda. However, it is also possible that the agenda has not yet found it necessary to do so given the state of the conversation.\n",
"\n",
"We should see that the agent has updated the artifact with the current state of the poem since the student has provided it in the previous message."
]
},
{
"cell_type": "code",
"execution_count": 11,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Current agenda:\n",
"1. [2 turns] Review David's poem and give initial feedback\n",
"2. [2 turns] Have David revise his poem based on the feedback\n",
"3. [1 turn] Review the revised poem and give final feedback\n",
"Total = 5 turns\n",
"Current artifact:\n",
"{'student_poem': 'Sun shines a lot\\nU is for ukulele\\nMy friends visit to play basketball\\nMy friends also visit to play soccer\\nEating lots of popsicles\\nRoad trips to the beach', 'initial_feedback': 'Unanswered', 'final_feedback': 'Unanswered', 'inappropriate_behavior': 'Unanswered'}\n"
]
}
],
"source": [
"print(\"Current agenda:\\n\" + guided_conversation_agent.agenda.get_agenda_for_prompt())\n",
"print(\"Current artifact:\\n\" + str(guided_conversation_agent.artifact.get_artifact_for_prompt()))"
]
},
{
"cell_type": "code",
"execution_count": 12,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Awesome updates, David! Your poem is really capturing the fun of summer. Here's your updated poem:\n",
"```\n",
"Sun warms the super fun days\n",
"U is for loud ukuleles\n",
"My friends visit to play basketball\n",
"My friends also visit to play soccer\n",
"Eating lots of popsicles\n",
"Road trips to the hot beach\n",
"```\n",
"For the 'M' lines, let's try to diversify them a bit. Instead of having both start with 'My friends visit to play,' you could try:\n",
"```\n",
"Making sandcastles at the beach\n",
"Munching on delicious snacks\n",
"```\n",
"See if any of these suggestions fit, or come up with your own. Give it a try and let me know your thoughts!\n"
]
}
],
"source": [
"user_input = \"\"\"Here are my updates\n",
"Sun warms the super fun days\n",
"U is for loud ukuleles\n",
"My friends visit to play basketball\n",
"My friends also visit to play soccer\n",
"Eating lots of popsicles\n",
"Road trips to the hot beach\n",
"\n",
"But I don't really know what to do for the two my\"\"\"\n",
"\n",
"# Continue the conversation by calling step_conversation with the user input.\n",
"response = await guided_conversation_agent.step_conversation(user_input)\n",
"print(response.ai_message)"
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Great job, David! Here's your revised poem:\n",
"```\n",
"Sun warms the super fun days!\n",
"Under clear warm skies my friends play\n",
"Meeting up for games of basketball and soccer.\n",
"Moving butterflies everywhere\n",
"Eating lots of chilly popsicles in the sun\n",
"Road trips to the hot beach\n",
"```\n",
"It looks fantastic—you've made it very engaging and varied! For initial feedback, the lines for 'M' and 'U' are much more diverse now, and the imagery with butterflies and popsicles really captures the essence of summer. Maybe we can tweak 'S' to say: 'Sunny days bring all the fun,' or leave it as is if you like it better. Would you like to make any more changes?\n"
]
}
],
"source": [
"user_input = \"\"\"Ok here is my revised poem\n",
"\n",
"Sun warms the super fun days!\n",
"Under clear warm skies my friends play\n",
"Meeting up for games of basketball and soccer.\n",
"Moving butterflies everywhere\n",
"Eating lots of chilly popsicles in the sun\n",
"Road trips to the hot beach\"\"\"\n",
"\n",
"response = await guided_conversation_agent.step_conversation(user_input)\n",
"print(response.ai_message)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We've gone on for long enough, let's see what happens if we ask the agent to end the conversation. \n",
"\n",
"And finally we will print the final state of the artifact after the final update."
]
},
{
"cell_type": "code",
"execution_count": 14,
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"No artifact change during final update due to: No tool was called\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"I will terminate this conversation now. Thank you for your time!\n"
]
}
],
"source": [
"user_input = \"I'm done for today, goodbye!!\"\n",
"\n",
"response = await guided_conversation_agent.step_conversation(user_input)\n",
"print(response.ai_message)"
]
},
{
"cell_type": "code",
"execution_count": 15,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Current artifact:\n",
"{'student_poem': 'Sun warms the super fun days!\\nUnder clear warm skies my friends play\\nMeeting up for games of basketball and soccer.\\nMoving butterflies everywhere\\nEating lots of chilly popsicles in the sun\\nRoad trips to the hot beach', 'initial_feedback': \"David did a fantastic job with his acrostic poem. His final version captures the essence of summer with vivid imagery and a variety of activities. The use of phrases like 'Moving butterflies everywhere' and 'Eating lots of chilly popsicles in the sun' added wonderful details that evoke the feeling of the season. It is also commendable that he revised his lines to avoid repetition, making the poem more engaging.\", 'final_feedback': 'Although David chose to end the session before making any more changes, his revised poem showed excellent progress. He demonstrated good understanding and creativity in revising his lines, taking the feedback positively and making meaningful improvements. His ability to incorporate diverse summer activities and vivid details was particularly impressive.', 'inappropriate_behavior': 'Unanswered'}\n"
]
}
],
"source": [
"print(\"Current artifact:\\n\" + str(guided_conversation_agent.artifact.get_artifact_for_prompt()))"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.9"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
@@ -0,0 +1,580 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# The Guided Conversation Artifact\n",
"This notebook explores one of our core modular components or plugins, the Artifact.\n",
"\n",
"The artifact is a form, or a type of working memory for the agent. We implement it using a Pydantic BaseModel. As the conversation creator, you can define an arbitrary BaseModel that includes the fields you want the agent to fill out during the conversation. "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Motivating Example - Collecting Information from a User\n",
"\n",
"Let's setup an artifact where the goal is to collect information about a customer's issue with a service."
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {},
"outputs": [],
"source": [
"from typing import Literal\n",
"\n",
"from pydantic import BaseModel, Field, conlist\n",
"\n",
"\n",
"class Issue(BaseModel):\n",
" incident_type: Literal[\"Service Outage\", \"Degradation\", \"Billing\", \"Security\", \"Data Loss\", \"Other\"] = Field(\n",
" description=\"A high level type describing the incident.\"\n",
" )\n",
" description: str = Field(description=\"A detailed description of what is going wrong.\")\n",
" affected_services: conlist(str, min_length=0) = Field(description=\"The services affected by the incident.\")\n",
"\n",
"\n",
"class OutageArtifact(BaseModel):\n",
" name: str = Field(description=\"How to address the customer.\")\n",
" company: str = Field(description=\"The company the customer works for.\")\n",
" role: str = Field(description=\"The role of the customer.\")\n",
" email: str = Field(description=\"The best email to contact the customer.\", pattern=r\"^/^.+@.+$/$\")\n",
" phone: str = Field(description=\"The best phone number to contact the customer.\", pattern=r\"^\\d{3}-\\d{3}-\\d{4}$\")\n",
"\n",
" incident_start: int = Field(\n",
" description=\"About how many hours ago the incident started.\",\n",
" )\n",
" incident_end: int = Field(\n",
" description=\"About how many hours ago the incident ended. If the incident is ongoing, set this to 0.\",\n",
" )\n",
"\n",
" issues: conlist(Issue, min_length=1) = Field(description=\"The issues the customer is experiencing.\")\n",
" additional_comments: conlist(str, min_length=0) = Field(\"Any additional comments the customer has.\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Let's initialize the artifact as a standalone module.\n",
"\n",
"It requires a Kernel and LLM Service, alongside a Conversation object."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantic_kernel import Kernel\n",
"from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion\n",
"\n",
"from guided_conversation.plugins.artifact import Artifact\n",
"from guided_conversation.utils.conversation_helpers import Conversation\n",
"\n",
"kernel = Kernel()\n",
"service_id = \"artifact_chat_completion\"\n",
"chat_service = AzureChatCompletion(\n",
" service_id=service_id,\n",
" deployment_name=\"gpt-4o-2024-05-13\",\n",
" api_version=\"2024-05-01-preview\",\n",
")\n",
"kernel.add_service(chat_service)\n",
"\n",
"# Initialize the artifact\n",
"artifact = Artifact(kernel, service_id, OutageArtifact, max_artifact_field_retries=2)\n",
"conversation = Conversation()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"To power the Artifact's ability to automatically fix issues, we provide the conversation history as additional context."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantic_kernel.contents import AuthorRole, ChatMessageContent\n",
"\n",
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.ASSISTANT,\n",
" content=\"Hello! I'm here to help you with your issue. Can you tell me your name, company, and role?\",\n",
" )\n",
")\n",
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.USER,\n",
" content=\"Yes my name is Jane Doe, I work at Contoso, and I'm a database uhh administrator.\",\n",
" )\n",
")\n",
"\n",
"result = await artifact.update_artifact(\n",
" field_name=\"name\",\n",
" field_value=\"Jane Doe\",\n",
" conversation=conversation,\n",
")\n",
"conversation.add_messages(result.messages)\n",
"\n",
"result = await artifact.update_artifact(\n",
" field_name=\"company\",\n",
" field_value=\"Contoso\",\n",
" conversation=conversation,\n",
")\n",
"conversation.add_messages(result.messages)\n",
"\n",
"result = await artifact.update_artifact(\n",
" field_name=\"role\",\n",
" field_value=\"Database Administrator\",\n",
" conversation=conversation,\n",
")\n",
"conversation.add_messages(result.messages)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Let's see how the artifact was updated with these valid updates and the resulting conversation messages that were generated.\n",
"\n",
"The Artifact creates messages whenever a field is updated for use in downstream agents like the main GuidedConversation."
]
},
{
"cell_type": "code",
"execution_count": 16,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Conversation up to this point:\n",
"Assistant: Hello! I'm here to help you with your issue. Can you tell me your name, company, and role?\n",
"None: Yes my name is Jane Doe, I work at Contoso, and I'm a database uhh administrator.\n",
"Assistant updated name to Jane Doe\n",
"Assistant updated company to Contoso\n",
"Assistant updated role to Database Administrator\n",
"\n",
"Current state of the artifact:\n",
"{'name': 'Jane Doe', 'company': 'Contoso', 'role': 'Database Administrator', 'email': 'Unanswered', 'phone': 'Unanswered', 'incident_start': 'Unanswered', 'incident_end': 'Unanswered', 'issues': 'Unanswered', 'additional_comments': 'Unanswered'}\n"
]
}
],
"source": [
"print(f\"Conversation up to this point:\\n{conversation.get_repr_for_prompt()}\\n\")\n",
"print(f\"Current state of the artifact:\\n{artifact.get_artifact_for_prompt()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Next we test an invalid update on a field with a regex. The agent should not update the artifact and\n",
"instead resume the conversation because the provided email is incomplete."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"Error updating field email: 1 validation error for Artifact\n",
"email\n",
" String should match pattern '^/^.+@.+$/$|Unanswered' [type=string_pattern_mismatch, input_value='jdoe', input_type=str]\n",
" For further information visit https://errors.pydantic.dev/2.8/v/string_pattern_mismatch. Retrying...\n"
]
}
],
"source": [
"conversation.add_messages(\n",
" ChatMessageContent(role=AuthorRole.ASSISTANT, content=\"What is the best email to contact you at?\")\n",
")\n",
"conversation.add_messages(ChatMessageContent(role=AuthorRole.USER, content=\"my email is jdoe\"))\n",
"result = await artifact.update_artifact(\n",
" field_name=\"email\",\n",
" field_value=\"jdoe\",\n",
" conversation=conversation,\n",
")\n",
"conversation.add_messages(result.messages)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"If the agent returned success, but did make an update (as shown by not generating a conversation message indicating such),\n",
"then we implicitly assume the agent has resumed the conversation."
]
},
{
"cell_type": "code",
"execution_count": 18,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Conversation up to this point:\n",
"Assistant: Hello! I'm here to help you with your issue. Can you tell me your name, company, and role?\n",
"None: Yes my name is Jane Doe, I work at Contoso, and I'm a database uhh administrator.\n",
"Assistant updated name to Jane Doe\n",
"Assistant updated company to Contoso\n",
"Assistant updated role to Database Administrator\n",
"Assistant: What is the best email to contact you at?\n",
"None: my email is jdoe\n"
]
}
],
"source": [
"print(f\"Conversation up to this point:\\n{conversation.get_repr_for_prompt()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now let's see what happens if we keep trying to update that failed field."
]
},
{
"cell_type": "code",
"execution_count": 19,
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"Error updating field email: 1 validation error for Artifact\n",
"email\n",
" String should match pattern '^/^.+@.+$/$|Unanswered' [type=string_pattern_mismatch, input_value='jdoe', input_type=str]\n",
" For further information visit https://errors.pydantic.dev/2.8/v/string_pattern_mismatch. Retrying...\n",
"Updating field email has failed too many times. Skipping.\n"
]
}
],
"source": [
"result = await artifact.update_artifact(\n",
" field_name=\"email\",\n",
" field_value=\"jdoe\",\n",
" conversation=conversation,\n",
")\n",
"\n",
"# And again\n",
"result = await artifact.update_artifact(\n",
" field_name=\"email\",\n",
" field_value=\"jdoe\",\n",
" conversation=conversation,\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"If we look at the current state of the artifact, we should see that the email has been removed\n",
"since it has now failed 3 times which is greater than the max_artifact_field_retries parameter we set\n",
"when we instantiated the artifact."
]
},
{
"cell_type": "code",
"execution_count": 20,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'name': 'Jane Doe',\n",
" 'company': 'Contoso',\n",
" 'role': 'Database Administrator',\n",
" 'phone': 'Unanswered',\n",
" 'incident_start': 'Unanswered',\n",
" 'incident_end': 'Unanswered',\n",
" 'issues': 'Unanswered',\n",
" 'additional_comments': 'Unanswered'}"
]
},
"execution_count": 20,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"artifact.get_artifact_for_prompt()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now let's move on to trying to update a more complex field: the issues field."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Conversation up to this point:\n",
"Assistant: Hello! I'm here to help you with your issue. Can you tell me your name, company, and role?\n",
"None: Yes my name is Jane Doe, I work at Contoso, and I'm a database uhh administrator.\n",
"Assistant updated name to Jane Doe\n",
"Assistant updated company to Contoso\n",
"Assistant updated role to Database Administrator\n",
"Assistant: What is the best email to contact you at?\n",
"None: my email is jdoe\n",
"Assistant: Can you tell me about the issues you're experiencing?\n",
"None: The latency of accessing our database service has increased by 200\\% in the last 24 hours, \n",
"even on a fresh instance. Additionally, we're seeing a lot of timeouts when trying to access the management portal.\n",
"Assistant updated issues to [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal.\", 'affected_services': ['Database Service', 'Database Management Portal']}]\n",
"\n",
"Current state of the artifact:\n",
"{'name': 'Jane Doe', 'company': 'Contoso', 'role': 'Database Administrator', 'phone': 'Unanswered', 'incident_start': 'Unanswered', 'incident_end': 'Unanswered', 'issues': [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal.\", 'affected_services': ['Database Service', 'Database Management Portal']}], 'additional_comments': 'Unanswered'}\n"
]
}
],
"source": [
"conversation.add_messages(\n",
" ChatMessageContent(role=AuthorRole.ASSISTANT, content=\"Can you tell me about the issues you're experiencing?\")\n",
")\n",
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.USER,\n",
" content=\"\"\"The latency of accessing our database service has increased by 200\\% in the last 24 hours, \n",
"even on a fresh instance. Additionally, we're seeing a lot of timeouts when trying to access the management portal.\"\"\",\n",
" )\n",
")\n",
"\n",
"result = await artifact.update_artifact(\n",
" field_name=\"issues\",\n",
" field_value=[\n",
" {\n",
" \"incident_type\": \"Degradation\",\n",
" \"description\": \"\"\"The latency of accessing the customer's database service has increased by 200% in the \\\n",
"last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal.\"\"\",\n",
" \"affected_services\": [\"Database Service\", \"Database Management Portal\"],\n",
" }\n",
" ],\n",
" conversation=conversation,\n",
")\n",
"conversation.add_messages(result.messages)\n",
"\n",
"print(f\"Conversation up to this point:\\n{conversation.get_repr_for_prompt()}\\n\")\n",
"print(f\"Current state of the artifact:\\n{artifact.get_artifact_for_prompt()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"To add another affected service, we can need to update the issues field with the new value again.\n",
"The obvious con of this approach is that the model generating the field_value has to regenerate the entire field_value.\n",
"However, the pro is that keeps the available tools simple for the model."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Conversation up to this point:\n",
"Assistant: Hello! I'm here to help you with your issue. Can you tell me your name, company, and role?\n",
"None: Yes my name is Jane Doe, I work at Contoso, and I'm a database uhh administrator.\n",
"Assistant updated name to Jane Doe\n",
"Assistant updated company to Contoso\n",
"Assistant updated role to Database Administrator\n",
"Assistant: What is the best email to contact you at?\n",
"None: my email is jdoe\n",
"Assistant: Can you tell me about the issues you're experiencing?\n",
"None: The latency of accessing our database service has increased by 200\\% in the last 24 hours, \n",
"even on a fresh instance. Additionally, we're seeing a lot of timeouts when trying to access the management portal.\n",
"Assistant updated issues to [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal.\", 'affected_services': ['Database Service', 'Database Management Portal']}]\n",
"Assistant: Is there anything else you'd like to add about the issues you're experiencing?\n",
"None: Yes another thing that is effected is access to billing information is very slow.\n",
"Assistant updated issues to [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal and slowdowns in the access to billing information.\", 'affected_services': ['Database Service', 'Database Management Portal', 'Billing portal']}]\n",
"\n",
"Current state of the artifact:\n",
"{'name': 'Jane Doe', 'company': 'Contoso', 'role': 'Database Administrator', 'phone': 'Unanswered', 'incident_start': 'Unanswered', 'incident_end': 'Unanswered', 'issues': [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal and slowdowns in the access to billing information.\", 'affected_services': ['Database Service', 'Database Management Portal', 'Billing portal']}], 'additional_comments': 'Unanswered'}\n"
]
}
],
"source": [
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.ASSISTANT,\n",
" content=\"Is there anything else you'd like to add about the issues you're experiencing?\",\n",
" )\n",
")\n",
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.USER,\n",
" content=\"Yes another thing that is effected is access to billing information is very slow.\",\n",
" )\n",
")\n",
"\n",
"result = await artifact.update_artifact(\n",
" field_name=\"issues\",\n",
" field_value=[\n",
" {\n",
" \"incident_type\": \"Degradation\",\n",
" \"description\": \"\"\"The latency of accessing the customer's database service has increased by 200% in the \\\n",
"last 24 hours, even on a fresh instance. They also report timeouts when trying to access the \\\n",
"management portal and slowdowns in the access to billing information.\"\"\",\n",
" \"affected_services\": [\"Database Service\", \"Database Management Portal\", \"Billing portal\"],\n",
" },\n",
" ],\n",
" conversation=conversation,\n",
")\n",
"conversation.add_messages(result.messages)\n",
"print(f\"Conversation up to this point:\\n{conversation.get_repr_for_prompt()}\\n\")\n",
"print(f\"Current state of the artifact:\\n{artifact.get_artifact_for_prompt()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now let's see what happens if we try to update a field that is not in the artifact."
]
},
{
"cell_type": "code",
"execution_count": 23,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Was the update successful? False\n",
"Conversation up to this point:\n",
"Assistant: Hello! I'm here to help you with your issue. Can you tell me your name, company, and role?\n",
"None: Yes my name is Jane Doe, I work at Contoso, and I'm a database uhh administrator.\n",
"Assistant updated name to Jane Doe\n",
"Assistant updated company to Contoso\n",
"Assistant updated role to Database Administrator\n",
"Assistant: What is the best email to contact you at?\n",
"None: my email is jdoe\n",
"Assistant: Can you tell me about the issues you're experiencing?\n",
"None: The latency of accessing our database service has increased by 200\\% in the last 24 hours, \n",
"even on a fresh instance. Additionally, we're seeing a lot of timeouts when trying to access the management portal.\n",
"Assistant updated issues to [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal.\", 'affected_services': ['Database Service', 'Database Management Portal']}]\n",
"Assistant: Is there anything else you'd like to add about the issues you're experiencing?\n",
"None: Yes another thing that is effected is access to billing information is very slow.\n",
"Assistant updated issues to [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal and slowdowns in the access to billing information.\", 'affected_services': ['Database Service', 'Database Management Portal', 'Billing portal']}]\n",
"\n",
"Current state of the artifact:\n",
"{'name': 'Jane Doe', 'company': 'Contoso', 'role': 'Database Administrator', 'phone': 'Unanswered', 'incident_start': 'Unanswered', 'incident_end': 'Unanswered', 'issues': [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal and slowdowns in the access to billing information.\", 'affected_services': ['Database Service', 'Database Management Portal', 'Billing portal']}], 'additional_comments': 'Unanswered'}\n"
]
}
],
"source": [
"result = await artifact.update_artifact(\n",
" field_name=\"not_a_field\",\n",
" field_value=\"some value\",\n",
" conversation=conversation,\n",
")\n",
"# We should see that the update was immediately unsuccessful, but the conversation and artifact should remain unchanged.\n",
"print(f\"Was the update successful? {result.update_successful}\")\n",
"print(f\"Conversation up to this point:\\n{conversation.get_repr_for_prompt()}\\n\")\n",
"print(f\"Current state of the artifact:\\n{artifact.get_artifact_for_prompt()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Finally, let's see what happens if we try to update a field with the incorrect type, but the correct information was provided in the conversation. \n",
"We should see the agent correctly updated the field correctly as an integer."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"Error updating field incident_start: 2 validation errors for Artifact\n",
"incident_start.int\n",
" Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='3 hours', input_type=str]\n",
" For further information visit https://errors.pydantic.dev/2.8/v/int_parsing\n",
"incident_start.literal['Unanswered']\n",
" Input should be 'Unanswered' [type=literal_error, input_value='3 hours', input_type=str]\n",
" For further information visit https://errors.pydantic.dev/2.8/v/literal_error. Retrying...\n",
"Agent failed to fix field incident_start. Retrying...\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Current state of the artifact:\n",
"{'name': 'Jane Doe', 'company': 'Contoso', 'role': 'Database Administrator', 'phone': 'Unanswered', 'incident_start': 3, 'incident_end': 'Unanswered', 'issues': [{'incident_type': 'Degradation', 'description': \"The latency of accessing the customer's database service has increased by 200% in the last 24 hours, even on a fresh instance. They also report timeouts when trying to access the management portal and slowdowns in the access to billing information.\", 'affected_services': ['Database Service', 'Database Management Portal', 'Billing portal']}], 'additional_comments': 'Unanswered'}\n"
]
}
],
"source": [
"conversation.add_messages(\n",
" ChatMessageContent(role=AuthorRole.ASSISTANT, content=\"How many hours ago did the incident start?\")\n",
")\n",
"conversation.add_messages(ChatMessageContent(role=AuthorRole.USER, content=\"about 3 hours ago\"))\n",
"result = await artifact.update_artifact(\n",
" field_name=\"incident_start\",\n",
" field_value=\"3 hours\",\n",
" conversation=conversation,\n",
")\n",
"\n",
"print(f\"Current state of the artifact:\\n{artifact.get_artifact_for_prompt()}\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.4"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
@@ -0,0 +1,286 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# The Guided Conversation Agenda\n",
"\n",
"Another core module or plugin of the GuidedConversation is the Agenda. This is a specialized Pydantic BaseModel that gives the agent the ability to explicitly reason about a longer term plan, or agenda, for the conversation. \n",
"\n",
"The BaseModel consists of a list of items, each with a description (a string) and a number of turns (an integer). It will raise an error if an input violates the type requirements. This check is particularly important for turn allocations. For example, sometimes a conversation agent provides fractional estimates (\"0.6 turns\") or broad ranges (\"5-20\" turns), both of which are meaningless. We also added additional validations which raise an error if the total number of turns allocated across items is invalid (e.g., it exceeds the number of remaining turns) depending on the resource constraint. \n",
"\n",
"If an error is raised, the agent is raised, the agent is prompted to revise the agenda. To prevent infinite loops, we imposed a limit on the number of retries."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Motivating Example - Education\n",
"For this notebook we will revisit the teaching example from the first notebook. In that demo, under the hood the agent was actually making mistakes in its allocation of turns, mostly in generating an invalid number of cumulative turns. However, thanks to the Agenda plugin, it was able to automatically detect and correct these mistakes before they snowballed.\n",
"\n",
"Let's start by setting up the Agenda plugin. It takes in a resource constraint type, which as a reminder controls conversation length. Currently it can be either *maximum* to set an upper limit and an *exact* mode for precise conversation lengths. Depending on the selected mode, the validation will differ. For example, for exact mode the total number of turns allocated across items must be exactly equal to the total number of turns available. While in maximum mode, the total number of turns allocated across items must be less than or equal to the total number of turns available."
]
},
{
"cell_type": "code",
"execution_count": 17,
"metadata": {},
"outputs": [],
"source": [
"from semantic_kernel import Kernel\n",
"from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion\n",
"from semantic_kernel.contents import AuthorRole, ChatMessageContent\n",
"\n",
"from guided_conversation.plugins.agenda import Agenda\n",
"from guided_conversation.utils.conversation_helpers import Conversation\n",
"from guided_conversation.utils.resources import ResourceConstraintMode\n",
"\n",
"RESOURCE_CONSTRAINT_TYPE = ResourceConstraintMode.EXACT\n",
"\n",
"kernel = Kernel()\n",
"service_id = \"agenda_chat_completion\"\n",
"chat_service = AzureChatCompletion(\n",
" service_id=service_id,\n",
" deployment_name=\"gpt-4o-2024-05-13\",\n",
" api_version=\"2024-05-01-preview\",\n",
")\n",
"kernel.add_service(chat_service)\n",
"\n",
"agenda = Agenda(\n",
" kernel=kernel, service_id=service_id, resource_constraint_mode=RESOURCE_CONSTRAINT_TYPE, max_agenda_retries=2\n",
")\n",
"\n",
"conversation = Conversation()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Here we provide an agenda that was generated by the Guided Conversation agent for the first turn of the conversation. \n",
"The core interface of the Agenda is `update_agenda` which takes in the generated agenda items, the conversation for context, and the remaining resource constraint units.\n",
"The expected format of the agenda is defined as follows in Pydantic:\n",
"```python\n",
"class _BaseAgendaItem(BaseModelLLM):\n",
" title: str = Field(description=\"Brief description of the item\")\n",
" resource: int = Field(description=\"Number of turns required for the item\")\n",
"\n",
"\n",
"class _BaseAgenda(BaseModelLLM):\n",
" items: list[_BaseAgendaItem] = Field(\n",
" description=\"Ordered list of items to be completed in the remainder of the conversation\",\n",
" default_factory=list,\n",
" )\n",
"```\n",
"\n",
"Since we defined the resource constraint type to be exact, the resource units must also add up exactly to the `remaining_turns` parameter.\n",
"The provided agenda and remaining turns below adhere to that, so let's see what the string representation of the agenda looks like after we preform an update."
]
},
{
"cell_type": "code",
"execution_count": 18,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"1. [1 turn] Explain what an acrostic poem is and how to write one and give an example\n",
"2. [2 turns] Have the student write their acrostic poem\n",
"3. [2 turns] Review and give initial feedback on the student's poem\n",
"4. [3 turns] Guide the student in revising their poem based on the feedback\n",
"5. [3 turns] Review the revised poem and provide final feedback\n",
"6. [3 turns] Address any remaining questions or details\n",
"Total = 14 turns\n"
]
}
],
"source": [
"generated_agenda = [\n",
" {\"title\": \"Explain what an acrostic poem is and how to write one and give an example\", \"resource\": 1},\n",
" {\"title\": \"Have the student write their acrostic poem\", \"resource\": 2},\n",
" {\"title\": \"Review and give initial feedback on the student's poem\", \"resource\": 2},\n",
" {\"title\": \"Guide the student in revising their poem based on the feedback\", \"resource\": 3},\n",
" {\"title\": \"Review the revised poem and provide final feedback\", \"resource\": 3},\n",
" {\"title\": \"Address any remaining questions or details\", \"resource\": 3},\n",
"]\n",
"\n",
"result = await agenda.update_agenda(\n",
" items=generated_agenda,\n",
" conversation=conversation,\n",
" remaining_turns=14,\n",
")\n",
"\n",
"\n",
"print(agenda.get_agenda_for_prompt())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Next, let's test out the ability of the agenda to detect and correct an agenda that does not follow the Pydantic model.\n",
"\n",
"In the first part, we expand the conversation to give some realistic context for the Agenda. Then, we provide an *invalid* agenda where the type of the `title` field is not a string.\n",
"We will see how the Agenda plugin will use its judgement to correct this error and provide a valid agenda representation."
]
},
{
"cell_type": "code",
"execution_count": 19,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Was the update successful? True\n",
"Agenda state: 1. [3 turns] Ask for the feedback\n",
"2. [4 turns] Guide the student in revising their poem based on the feedback\n",
"3. [3 turns] Review the revised poem and provide final feedback\n",
"4. [2 turns] Address any remaining questions or details\n",
"Total = 12 turns\n"
]
}
],
"source": [
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.ASSISTANT,\n",
" content=\"\"\"Hi David! Today, we're going to learn about acrostic poems. \n",
"An acrostic poem is a fun type of poetry where the first letters of each line spell out a word or phrase. Here's how you can write one:\n",
"1. Choose a word or phrase that you like. This will be the subject of your poem.\n",
"2. Write the letters of your chosen word or phrase vertically down the page.\n",
"3. Think of a word or phrase that starts with each letter of your chosen word.\n",
"4. Write these words or phrases next to the corresponding letters to create your poem.\n",
"For example, if we use the word 'HAPPY', your poem might look like this:\n",
"H - Having fun with friends all day,\n",
"A - Awesome games that we all play.\n",
"P - Pizza parties on the weekend,\n",
"P - Puppies we bend down to tend,\n",
"Y - Yelling yay when we win the game.\n",
"Now, why don't you try creating your own acrostic poem? Choose any word or phrase you like and follow the steps above. I can't wait to see what you come up with!\"\"\",\n",
" )\n",
")\n",
"\n",
"conversation.add_messages(ChatMessageContent(role=AuthorRole.USER, content=\"I want to choose cars\"))\n",
"\n",
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.ASSISTANT,\n",
" content=\"\"\"Great choice, David! 'Cars' sounds like a fun subject for your acrostic poem. \n",
"Be creative and let me know if you need any help as you write!\"\"\",\n",
" )\n",
")\n",
"\n",
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.USER,\n",
" content=\"\"\"Heres my first attempt\n",
"Cruising down the street. \n",
"Adventure beckons with stories untold. \\\n",
"R\n",
"S\"\"\",\n",
" )\n",
")\n",
"\n",
"result = await agenda.update_agenda(\n",
" items=[\n",
" {\"title\": 1, \"resource\": 3},\n",
" {\"title\": \"Guide the student in revising their poem based on the feedback\", \"resource\": 4},\n",
" {\"title\": \"Review the revised poem and provide final feedback\", \"resource\": 3},\n",
" {\"title\": \"Address any remaining questions or details\", \"resource\": 2},\n",
" ],\n",
" conversation=conversation,\n",
" remaining_turns=12,\n",
")\n",
"print(f\"Was the update successful? {result.update_successful}\")\n",
"print(f\"Agenda state: {agenda.get_agenda_for_prompt()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We see that the agent removed the invalid item and correctly reallocated the resource to other items.\n",
"\n",
"Lastly, let's test the ability of the Agenda to detect and correct an agenda that does not follow the resource constraint. \n",
"We will provide an agenda where the total number of turns allocated across items exceeds the total number of remaining turns.\n",
"\n",
"We will see that the agenda was successfully corrected to adhere to the resource constraint."
]
},
{
"cell_type": "code",
"execution_count": 20,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Was the update successful? True\n",
"Agenda state: 1. [7 turns] Review the revised poem and provide final feedback\n",
"2. [4 turns] Address any remaining questions or details\n",
"Total = 11 turns\n"
]
}
],
"source": [
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.ASSISTANT,\n",
" content=\"\"\"That's a great start, David! I love the imagery you've used in your poem. Let's continue with writing the \"R\" and \"S\" lines.\"\"\",\n",
" )\n",
")\n",
"\n",
"conversation.add_messages(\n",
" ChatMessageContent(\n",
" role=AuthorRole.USER,\n",
" content=\"\"\"Sure here's the rest of the poem:\n",
"Cruising down the street. \n",
"Adventure beckons with stories untold.\n",
"Revving engines, vroom vroom. \n",
"Steering through life's twists and turns.\"\"\",\n",
" )\n",
")\n",
"\n",
"result = await agenda.update_agenda(\n",
" items=[\n",
" {\"title\": \"Review the revised poem and provide final feedback\", \"resource\": 4},\n",
" {\"title\": \"Address any remaining questions or details\", \"resource\": 3},\n",
" ],\n",
" conversation=conversation,\n",
" remaining_turns=11,\n",
")\n",
"\n",
"print(f\"Was the update successful? {result.update_successful}\")\n",
"print(f\"Agenda state: {agenda.get_agenda_for_prompt()}\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.9"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
@@ -0,0 +1,423 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# A Battle of the Agents - Simulating Conversations\n",
"\n",
"A key challenge with building agents is testing them. Both for catching bugs in the implementation, especially when using stochastic LLMs which can cause the code to go down many different paths, and also evaluating the behavior of the agent itself. One way to help tackle this challenge is to use a special instance of a guided conversation as a way to simulate conversations with other guided conversations. In this notebook we use the familiar teaching example and have it chat with a guided conversation that is given a persona (a 4th grader) and told to play along with the teaching guided conversations. We will refer to this guided conversation as the \"simulation\" agent. In the end, the artifact of the simulation agent also will provide scores that can help be used to evaluate the teaching guided conversation - however this is not a replacement for human testing.\n"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"from pydantic import BaseModel, Field\n",
"\n",
"from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit\n",
"\n",
"\n",
"class StudentFeedbackArtifact(BaseModel):\n",
" student_poem: str = Field(description=\"The latest acrostic poem written by the student.\")\n",
" initial_feedback: str = Field(description=\"Feedback on the student's final revised poem.\")\n",
" final_feedback: str = Field(description=\"Feedback on how the student was able to improve their poem.\")\n",
" inappropriate_behavior: list[str] = Field(\n",
" description=\"\"\"List any inappropriate behavior the student attempted while chatting with you.\n",
"It is ok to leave this field Unanswered if there was none.\"\"\"\n",
" )\n",
"\n",
"\n",
"rules = [\n",
" \"DO NOT write the poem for the student.\",\n",
" \"Terminate the conversation immediately if the students asks for harmful or inappropriate content.\",\n",
" \"Do not counsel the student.\",\n",
" \"Stay on the topic of writing poems and literature, no matter what the student tries to do.\",\n",
"]\n",
"\n",
"\n",
"conversation_flow = \"\"\"1. Start by explaining interactively what an acrostic poem is.\n",
"2. Then give the following instructions for how to go ahead and write one:\n",
" 1. Choose a word or phrase that will be the subject of your acrostic poem.\n",
" 2. Write the letters of your chosen word or phrase vertically down the page.\n",
" 3. Think of a word or phrase that starts with each letter of your chosen word or phrase.\n",
" 4. Write these words or phrases next to the corresponding letters to create your acrostic poem.\n",
"3. Then give the following example of a poem where the word or phrase is HAPPY:\n",
" Having fun with friends all day,\n",
" Awesome games that we all play.\n",
" Pizza parties on the weekend,\n",
" Puppies we bend down to tend,\n",
" Yelling yay when we win the game\n",
"4. Finally have the student write their own acrostic poem using the word or phrase of their choice. Encourage them to be creative and have fun with it.\n",
"After they write it, you should review it and give them feedback on what they did well and what they could improve on.\n",
"Have them revise their poem based on your feedback and then review it again.\"\"\"\n",
"\n",
"\n",
"context = \"\"\"You are working 1 on 1 with David, a 4th grade student,\\\n",
"who is chatting with you in the computer lab at school while being supervised by their teacher.\"\"\"\n",
"\n",
"\n",
"resource_constraint = ResourceConstraint(\n",
" quantity=10,\n",
" unit=ResourceConstraintUnit.TURNS,\n",
" mode=ResourceConstraintMode.EXACT,\n",
")"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
"# Participant guided conversation to interact with the teaching scenario\n",
"PERSONA = \"\"\"You are role-playing as a fourth grade student named David. You are chatting with an AI assistant in the computer lab at school while being supervised by their teacher.\"\"\"\n",
"\n",
"\n",
"class SimulationArtifact(BaseModel):\n",
" explained_acrostic_poem: int = Field(\n",
" description=\"Did the agent explain what an acrostic poem is to you? 10 means they explained it well, 0 means they did not explain it at all.\"\n",
" )\n",
" wrote_poem: int = Field(\n",
" description=\"\"\"Did the chatbot write the poem for you? \\\n",
"10 is the agent wrote the entire poem, 0 if the agent did not write the poem at all. \\\n",
"Do not force the agent to write the poem for you.\"\"\"\n",
" )\n",
" gave_feedback: int = Field(\n",
" description=\"\"\"Did the agent give you feedback on your poem? \\\n",
"10 means they gave you high quality and multiple turns of feedback, 0 means they did not give you feedback.\"\"\"\n",
" )\n",
"\n",
"\n",
"rules_sim = [\n",
" \"NEVER send messages as an AI assistant.\",\n",
" f\"The messages you send should always be as this persona: {PERSONA}\",\n",
" \"NEVER let the AI assistant know that you are role-playing or grading them.\",\n",
" \"\"\"You should not articulate your thoughts/feelings perfectly. In the real world, users are lazy so we want to simulate that. \\\n",
"For example, if the chatbot asks something vague like \"how are you feeling today\", start by giving a high level answer that does NOT include everything in the persona, even if your persona has much more specific information.\"\"\",\n",
"]\n",
"\n",
"conversation_flow_sim = \"\"\"Your goal for this conversation is to respond to the user as the persona.\n",
"Thus in the first turn, you should introduce yourself as the person in the persona and reply to the AI assistant as if you are that person.\n",
"End the conversation if you feel like you are done.\"\"\"\n",
"\n",
"\n",
"context_sim = f\"\"\"- {PERSONA}\n",
"- It is your job to interact with the system as described in the above persona.\n",
"- You should use this information to guide the messages you send.\n",
"- In the artifact, you will be grading the assistant on how well they did. Do not share this with the assistant.\"\"\"\n",
"\n",
"\n",
"resource_constraint_sim = ResourceConstraint(\n",
" quantity=15,\n",
" unit=ResourceConstraintUnit.TURNS,\n",
" mode=ResourceConstraintMode.MAXIMUM,\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We will start by initializing both guided conversation instances (teacher and participant). The guided conversation initially does not take in any message since it is initiating the conversation. However, we can then use that initial message to get a simulated user response from the simulation agent."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"GUIDED CONVERSATION: Hi David! Today we're going to learn about a type of poem called an acrostic poem. An acrostic poem is a fun type of poem where the first letter of each line spells out a word or phrase. Ready to get started?\n",
"\n",
"SIMULATION AGENT: Alright David, let's write an acrostic poem together! Can you think of a word or phrase you'd like to use as the base for our poem?\n",
"\n"
]
}
],
"source": [
"from semantic_kernel import Kernel\n",
"from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion\n",
"from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token\n",
"\n",
"from guided_conversation.plugins.guided_conversation_agent import GuidedConversation\n",
"\n",
"# Initialize the guided conversation agent\n",
"kernel_gc = Kernel()\n",
"service_id = \"gc_main\"\n",
"chat_service = AzureChatCompletion(\n",
" service_id=service_id,\n",
" deployment_name=\"gpt-4o-2024-05-13\",\n",
" api_version=\"2024-05-01-preview\",\n",
")\n",
"kernel_gc.add_service(chat_service)\n",
"\n",
"guided_conversation_agent = GuidedConversation(\n",
" kernel=kernel_gc,\n",
" artifact=StudentFeedbackArtifact,\n",
" conversation_flow=conversation_flow,\n",
" context=context,\n",
" rules=rules,\n",
" resource_constraint=resource_constraint,\n",
" service_id=service_id,\n",
")\n",
"\n",
"# Initialize the simulation agent\n",
"kernel_sim = Kernel()\n",
"service_id_sim = \"gc_simulation\"\n",
"chat_service = AzureChatCompletion(\n",
" service_id=service_id_sim,\n",
" deployment_name=\"gpt-4o-2024-05-13\",\n",
" api_version=\"2024-05-01-preview\",\n",
" ad_token_provider=get_entra_auth_token,\n",
")\n",
"kernel_sim.add_service(chat_service)\n",
"\n",
"simulation_agent = GuidedConversation(\n",
" kernel=kernel_sim,\n",
" artifact=SimulationArtifact,\n",
" conversation_flow=conversation_flow_sim,\n",
" context=context_sim,\n",
" rules=rules_sim,\n",
" resource_constraint=resource_constraint_sim,\n",
" service_id=service_id_sim,\n",
")\n",
"\n",
"response = await guided_conversation_agent.step_conversation()\n",
"print(f\"GUIDED CONVERSATION: {response.ai_message}\\n\")\n",
"\n",
"response_sim = await simulation_agent.step_conversation(response.ai_message)\n",
"print(f\"SIMULATION AGENT: {response_sim.ai_message}\\n\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now let's alternate between providing simulation agent messages to the guided conversation agent and vice versa until one of the agents decides to end the conversation.\n",
"\n",
"After we will show the final artifacts for each agent."
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"GUIDED CONVERSATION: Great, David! Let's go through the steps to write an acrostic poem:\n",
" 1. Choose a word or phrase to be the subject of your acrostic poem.\n",
" 2. Write the letters of your chosen word or phrase vertically down the page.\n",
" 3. Think of a word or phrase that starts with each letter of your chosen word or phrase.\n",
" 4. Write these words or phrases next to the corresponding letters to create your acrostic poem. Does that make sense? Let me know if you need any help!\n",
"\n",
"SIMULATION AGENT: Yep, that makes sense! How about we pick a word or phrase first? Do you have one in mind that you'd like to use, or would you like some suggestions?\n",
"\n",
"GUIDED CONVERSATION: Awesome, David! Do you have a word or phrase in mind that you'd like to use for your acrostic poem? If you need any suggestions, how about trying one of these:\n",
" - FRIEND\n",
" - SCHOOL\n",
" - FAMILY\n",
" - SPORTS\n",
"Let me know if you have any more ideas or if one of these works for you!\n",
"\n",
"SIMULATION AGENT: Hi! I like the suggestions. I'll use the word 'FRIEND'. How should I start it?\n",
"\n",
"GUIDED CONVERSATION: Great choice, David! Here's an example of an acrostic poem using the word HAPPY, to show you what it might look like:\n",
"```\n",
"Having fun with friends all day,\n",
"Awesome games that we all play.\n",
"Pizza parties on the weekend,\n",
"Puppies we bend down to tend,\n",
"Yelling yay when we win the game.\n",
"```\n",
"Now, you can start your poem with the word FRIEND. Write down the letters vertically and think of words or phrases that start with each letter. Give it a try, and let me know if you need any help!\n",
"\n",
"SIMULATION AGENT: Perfect choice! So let's start. For 'F', you could write something like 'Friendly smiles'. Now it's your turn. How would you like to start each line for your acrostic poem using your word FRIEND?\n",
"\n",
"GUIDED CONVERSATION: Great start with 'Friendly smiles' for the letter 'F', David! Now let's continue with the other letters. Think of a word or phrase that starts with each letter in 'FRIEND'. Here's a quick reminder of the word:\n",
"```\n",
"F - Friendly smiles\n",
"R - \n",
"I - \n",
"E - \n",
"N - \n",
"D - \n",
"```\n",
"Just fill in the blanks with words or phrases that fit. Take your time and have fun with it. Let me know if you need any help or ideas!\n",
"\n",
"SIMULATION AGENT: You're doing great, David! Let's keep going. How about 'Reliable' for 'R'? And for 'I', maybe 'Inspiring'? Do you have ideas for 'E', 'N', and 'D'? Feel free to come up with your own words or use mine!\n",
"\n",
"GUIDED CONVERSATION: Awesome start, David! For the letter 'E', you could write something like 'Energetic play'. Now let's come up with something for 'N' and 'D'. How about 'Nurturing hands' for 'N', and 'Dependable friend' for 'D'? Or you can come up with your own words or phrases if you'd like! Let's complete the poem and then we can review it together. \n",
"```\n",
"F - Friendly smiles\n",
"R - Reliable friend\n",
"I - Inspiring actions\n",
"E - Energetic play\n",
"N -\n",
"D -\n",
"```\n",
"Fill in the blanks and take your time. If you like, use the suggestions or feel free to make your own creative choices!\n",
"\n",
"SIMULATION AGENT: David, your poem is looking fantastic! Let's finalize it. How about 'Nurturing hands' for 'N' and 'Dependable friend' for 'D'? Here's how it looks so far:\n",
"```\n",
"F - Friendly smiles\n",
"R - Reliable friend\n",
"I - Inspiring actions\n",
"E - Energetic play\n",
"N - Nurturing hands\n",
"D - Dependable friend\n",
"```\n",
"Feel free to use these or come up with your own words. Once it's done, let's review it together!\n",
"\n",
"GUIDED CONVERSATION: David, your poem looks fantastic! Just to finalize:\n",
"```\n",
"F - Friendly smiles\n",
"R - Reliable friend\n",
"I - Inspiring actions\n",
"E - Energetic play\n",
"N - Nurturing hands\n",
"D - Dependable friend\n",
"```\n",
"If you want to use these suggestions, thats great, or feel free to tweak them as you like. Once you confirm it, we can review your wonderful acrostic poem!\n",
"\n",
"SIMULATION AGENT: David, your poem is fantastic! Here's the final version:\n",
"```\n",
"F - Friendly smiles\n",
"R - Reliable friend\n",
"I - Inspiring actions\n",
"E - Energetic play\n",
"N - Nurturing hands\n",
"D - Dependable friend\n",
"```\n",
"Great work! Would you like any feedback or thoughts on it?\n",
"\n",
"GUIDED CONVERSATION: David, your acrostic poem looks great! Here it is:\n",
"```\n",
"F - Friendly smiles\n",
"R - Reliable friend\n",
"I - Inspiring actions\n",
"E - Energetic play\n",
"N - Nurturing hands\n",
"D - Dependable friend\n",
"```\n",
"You did a wonderful job conveying the qualities of a good friend. I especially like 'Friendly smiles' and 'Inspiring actions'. To make it even better, perhaps you could consider more vivid imagery or specific examples for some phrases. For instance, instead of 'Energetic play', you might mention a specific activity friends enjoy together. What do you think? Let's revise it together!\n",
"\n",
"SIMULATION AGENT: Thank you! I'm happy with my poem. Maybe I need help with the 'Energetic play' part. What could I write instead?\n",
"\n",
"GUIDED CONVERSATION: Sure, David! Let's make 'Energetic play' more vivid. How about describing a specific activity? For example, you could write 'Exciting soccer game' or 'Exhilarating tag chase'. Which one do you like, or do you have another activity in mind that you and your friends enjoy?\n",
"\n",
"SIMULATION AGENT: Those are great suggestions! I'll choose 'Exciting soccer game'. Thank you for the help!\n",
"\n",
"GUIDED CONVERSATION: Your poem looks wonderful, David! Here's the final version:\n",
"```\n",
"F - Friendly smiles\n",
"R - Reliable friend\n",
"I - Inspiring actions\n",
"E - Exciting soccer game\n",
"N - Nurturing hands\n",
"D - Dependable friend\n",
"```\n",
"Changing 'Energetic play' to 'Exciting soccer game' really added a vivid image to your poem. Great job! Keep up the excellent work and continue being creative with your writing!\n",
"\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"No artifact change during final update due to: No tool was called\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"SIMULATION AGENT: I will terminate this conversation now. Thank you for your time!\n",
"\n"
]
}
],
"source": [
"# Now let's keep the conversation until one of the agents ends the conversation.\n",
"while (not response.is_conversation_over) and (not response_sim.is_conversation_over):\n",
" response = await guided_conversation_agent.step_conversation(response_sim.ai_message)\n",
" print(f\"GUIDED CONVERSATION: {response.ai_message}\\n\")\n",
"\n",
" response_sim = await simulation_agent.step_conversation(response.ai_message)\n",
" print(f\"SIMULATION AGENT: {response_sim.ai_message}\\n\")"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'explained_acrostic_poem': 10, 'wrote_poem': 7, 'gave_feedback': 10}"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"simulation_agent.artifact.get_artifact_for_prompt()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'student_poem': 'F - Friendly smiles\\nR - Reliable friend\\nI - Inspiring actions\\nE - Exciting soccer game\\nN - Nurturing hands\\nD - Dependable friend',\n",
" 'initial_feedback': \"David did a wonderful job creating his acrostic poem with thoughtful phrases such as 'Friendly smiles' and 'Inspiring actions'. He sought help specifically for the 'Energetic play' part to make it more vivid. Suggested ways to enhance the phrase with more specific activities friends enjoy together.\",\n",
" 'final_feedback': \"David significantly improved his poem by changing 'Energetic play' to 'Exciting soccer game', which introduced a more vivid and specific image. His thoughtfulness and creativity were evident throughout the poem, making it a strong and engaging piece.\",\n",
" 'inappropriate_behavior': 'Unanswered'}"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"guided_conversation_agent.artifact.get_artifact_for_prompt()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.9"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
@@ -0,0 +1,55 @@
[tool.poetry]
name = "guided_conversation"
version = "0.1.0"
description = ""
authors = ["DavidKoleczek <dkoleczek@microsoft.com>", "natalieisak", "christyang-ms", "dasham8"]
license = "MIT"
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10,<3.13"
azure-identity = "^1.18"
semantic-kernel = { git = "https://github.com/microsoft/semantic-kernel.git", branch = "main", subdirectory = "python" }
pydantic = "^2.8"
python-dotenv = "^1.0"
[tool.poetry.dev-dependencies]
ipykernel = "*"
[tool.poetry.group.lint.dependencies]
ruff = "*"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
line-length = 120
target-version = "py311"
[tool.ruff.lint]
select = [
"F", # pyflakes
"E", # pycodestyle
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"RUF", # ruff
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"ISC", # flake8-implicit-str-concat
"PTH", # flake8-use-pathlib
"SIM", # flake8-simplify
"TID", # flake8-tidy-imports
]
ignore = ["E501"]
unfixable = ["F401"]
[tool.ruff.lint.isort]
force-sort-within-sections = true
split-on-trailing-comma = false
known-first-party = ["guided_conversation"]
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
+94
View File
@@ -0,0 +1,94 @@
# Semantic Kernel as MCP Server
This sample demonstrates how to expose your Semantic Kernel instance or a Agent as an MCP (Model Context Protocol) server.
## Getting Started with Stdio
To run these samples using the `stdio` transport (default), set up your MCP host (like [Claude Desktop](https://claude.ai/download) or [VSCode GitHub Copilot Agents](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)) with the following configuration:
```json
{
"mcpServers": {
"sk": {
"command": "uv",
"args": [
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
"run",
"sk_mcp_server.py"
],
"env": {
"OPENAI_API_KEY": "<your_openai_api_key>",
"OPENAI_CHAT_MODEL_ID": "gpt-4o-mini"
}
},
"agent": {
"command": "uv",
"args": [
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
"run",
"agent_mcp_server.py"
],
"env": {
"AZURE_AI_AGENT_PROJECT_CONNECTION_STRING": "<your azure connection string>",
"AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME": "<your azure model deployment name>",
}
}
}
}
```
Alternatively, you can run the server directly with the following command:
```bash
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server run sk_mcp_server.py
```
or:
```bash
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server run agent_mcp_server.py
```
## Getting Started with SSE
To run these samples as an SSE (Server-Sent Events) server, set the same environment variables as above and run the following command:
```bash
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server run sk_mcp_server.py --transport sse --port 8000
```
or:
```bash
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server run agent_mcp_server.py --transport sse --port 8000
```
This will start a server that listens for incoming requests on port `8000`.
> [!NOTE]
> By default the SSE server binds to `127.0.0.1` (loopback) and only accepts requests
> with a loopback `Host` header and, when present, a loopback `Origin` header. A local
> MCP server exposes tools, plugins and model providers backed by your own credentials,
> so it is good practice to keep it reachable only from your own machine. The
> [MCP specification](https://modelcontextprotocol.io/) recommends validating `Origin`
> and binding to loopback, in part to guard against [DNS rebinding](https://en.wikipedia.org/wiki/DNS_rebinding).
>
> You can override the bind address with `--host`, e.g. `--host 0.0.0.0` to expose the
> server on the network. Do this only on a trusted network. The bundled Host/Origin
> checks only allow loopback callers, so a non-loopback deployment needs proper
> authentication - see the [`mcp_with_oauth`](../mcp_with_oauth/) sample for the
> authenticated, Streamable-HTTP pattern recommended for production.
---
In both cases, `uv` will ensure that `semantic-kernel` is installed with the `mcp` extra in a temporary virtual environment.
## Extending the sample
The *sk_mcp_server* sample creates two functions:
- `echo-echo_function`: A simple function that echoes back the input.
- `prompt-prompt`: a function that uses a Semantic Kernel prompt to generate a response.
The *agent_mcp_server* sample creates a simple agent that uses the Azure OpenAI service to generate a response.
It exposes a single function:
- `mcp-host`: A function that uses the Azure OpenAI service to generate a response.
Once the server is created, you get a `mcp.server.lowlevel.Server` object, which you can then extend to add further functionality, like resources or prompts.
@@ -0,0 +1,228 @@
# /// script # noqa: CPY001
# dependencies = [
# "semantic-kernel[mcp]",
# ]
# ///
# Copyright (c) Microsoft. All rights reserved.
import argparse
import ipaddress
import logging
from typing import Annotated, Any, Literal
import anyio
from azure.identity.aio import AzureCliCredential
from semantic_kernel.agents import AzureAIAgent, AzureAIAgentSettings
from semantic_kernel.functions import kernel_function
logger = logging.getLogger(__name__)
"""
This sample demonstrates how to expose an Agent as a MCP server.
To run this sample, set up your MCP host (like Claude Desktop or VSCode Github Copilot Agents)
with the following configuration:
```json
{
"mcpServers": {
"sk": {
"command": "uv",
"args": [
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
"run",
"agent_mcp_server.py"
],
"env": {
"AZURE_AI_AGENT_PROJECT_CONNECTION_STRING": "<your azure connection string>",
"AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME": "<your azure model deployment name>",
}
}
}
}
```
Alternatively, you can run this as a SSE server, by setting the same environment variables as above,
and running the following command:
```bash
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server \
run agent_mcp_server.py --transport sse --port 8000
```
This will start a server that listens for incoming requests on port 8000.
In both cases, uv will make sure to install semantic-kernel with the mcp extra for you in a temporary venv.
"""
def is_loopback_host(host: str) -> bool:
"""Return True if the host refers to a loopback interface (incl. IPv6 ::1)."""
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def parse_arguments():
parser = argparse.ArgumentParser(description="Run the Semantic Kernel MCP server.")
parser.add_argument(
"--transport",
type=str,
choices=["sse", "stdio"],
default="stdio",
help="Transport method to use (default: stdio).",
)
parser.add_argument(
"--port",
type=int,
default=None,
help="Port to use for SSE transport (required if transport is 'sse').",
)
parser.add_argument(
"--host",
type=str,
default="127.0.0.1",
help=(
"Host/interface to bind the SSE server to (default: 127.0.0.1). "
"Binding to anything other than loopback (e.g. 0.0.0.0) exposes the server "
"to the network and should only be done on a trusted network with authentication added."
),
)
args = parser.parse_args()
if args.transport == "sse" and args.port is None:
parser.error("--port is required when --transport is 'sse'.")
return args
# Define a simple plugin for the sample
class MenuPlugin:
"""A sample Menu Plugin used for the sample."""
@kernel_function(description="Provides a list of specials from the menu.")
def get_specials(self) -> Annotated[str, "Returns the specials from the menu."]:
return """
Special Soup: Clam Chowder
Special Salad: Cobb Salad
Special Drink: Chai Tea
"""
@kernel_function(description="Provides the price of the requested menu item.")
def get_item_price(
self, menu_item: Annotated[str, "The name of the menu item."]
) -> Annotated[str, "Returns the price of the menu item."]:
return "$9.99"
async def run(transport: Literal["sse", "stdio"] = "stdio", port: int | None = None, host: str = "127.0.0.1") -> None:
async with (
# 1. Login to Azure and create a Azure AI Project Client
AzureCliCredential() as creds,
AzureAIAgent.create_client(credential=creds) as client,
):
agent = AzureAIAgent(
client=client,
definition=await client.agents.create_agent(
model=AzureAIAgentSettings().model_deployment_name,
name="Host",
instructions="Answer questions about the menu.",
),
plugins=[MenuPlugin()], # add the sample plugin to the agent
)
server = agent.as_mcp_server()
if transport == "sse" and port is not None:
import nest_asyncio
import uvicorn
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.responses import PlainTextResponse
from starlette.routing import Mount, Route
from starlette.types import ASGIApp, Receive, Scope, Send
# A local MCP server is a security boundary, not a generic web server: it exposes
# tools, plugins and model providers backed by the developer's credentials. Without
# Host/Origin validation a malicious web page could use DNS rebinding to reach this
# loopback listener from the victim's browser and invoke the exposed MCP tools.
# The MCP spec therefore requires servers to validate Origin and bind to loopback.
allowed_hosts = [
"localhost",
"127.0.0.1",
"[::1]",
f"localhost:{port}",
f"127.0.0.1:{port}",
f"[::1]:{port}",
]
allowed_origins = {
"http://localhost",
"http://127.0.0.1",
"http://[::1]",
f"http://localhost:{port}",
f"http://127.0.0.1:{port}",
f"http://[::1]:{port}",
}
class OriginValidationMiddleware:
"""Reject requests with an untrusted Origin header (DNS-rebinding defense)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http":
origin = dict(scope["headers"]).get(b"origin")
if origin is not None:
try:
origin_value = origin.decode("ascii")
except UnicodeDecodeError:
origin_value = None
if origin_value not in allowed_origins:
response = PlainTextResponse("Forbidden: invalid Origin header", status_code=403)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
sse = SseServerTransport("/messages/")
async def handle_sse(request):
async with sse.connect_sse(request.scope, request.receive, request._send) as (
read_stream,
write_stream,
):
await server.run(read_stream, write_stream, server.create_initialization_options())
starlette_app = Starlette(
debug=False,
routes=[
Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse.handle_post_message),
],
middleware=[
Middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts),
Middleware(OriginValidationMiddleware),
],
)
if not is_loopback_host(host):
logger.warning(
"Binding the MCP SSE server to %s exposes it beyond loopback. The bundled Host/Origin "
"checks only allow loopback callers; for a network-reachable or credentialed deployment "
"add proper authentication (see the mcp_with_oauth sample) before doing this.",
host,
)
nest_asyncio.apply()
uvicorn.run(starlette_app, host=host, port=port) # nosec
elif transport == "stdio":
from mcp.server.stdio import stdio_server
async def handle_stdin(stdin: Any | None = None, stdout: Any | None = None) -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
await handle_stdin()
if __name__ == "__main__":
args = parse_arguments()
anyio.run(run, args.transport, args.port, args.host)
@@ -0,0 +1,83 @@
# /// script # noqa: CPY001
# dependencies = [
# "semantic-kernel[mcp]",
# ]
# ///
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Any
import anyio
from mcp.server.stdio import stdio_server
from semantic_kernel import Kernel
from semantic_kernel.prompt_template import InputVariable, KernelPromptTemplate, PromptTemplateConfig
logger = logging.getLogger(__name__)
"""
This sample demonstrates how to expose a Semantic Kernel prompt through a MCP server.
To run this sample, set up your MCP host (like Claude Desktop or VSCode Github Copilot Agents)
with the following configuration:
```json
{
"mcpServers": {
"sk_release_notes": {
"command": "uv",
"args": [
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
"run",
"mcp_server_with_prompts.py"
],
}
}
}
```
Note: You might need to set the uv to it's full path.
"""
template = """{{$messages}}
---
Group the following PRs into one of these buckets for release notes, keeping the same order:
-New Features
-Enhancements and Improvements
-Bug Fixes
-Python Package Updates
Include the output in raw markdown.
"""
def run() -> None:
"""Run the MCP server with the release notes prompt template."""
kernel = Kernel()
prompt = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(
name="release_notes_prompt",
description="This creates the prompts for a full set of release notes based on the PR messages given.",
template=template,
input_variables=[
InputVariable(
name="messages",
description="These are the PR messages, they are a single string with new lines.",
is_required=True,
json_schema='{"type": "string"}',
)
],
)
)
server = kernel.as_mcp_server(server_name="sk_release_notes", prompts=[prompt])
async def handle_stdin(stdin: Any | None = None, stdout: Any | None = None) -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
anyio.run(handle_stdin)
if __name__ == "__main__":
run()
@@ -0,0 +1,116 @@
# /// script # noqa: CPY001
# dependencies = [
# "semantic-kernel[mcp]",
# ]
# ///
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Annotated, Any
import anyio
from mcp import types
from mcp.server.lowlevel import Server
from mcp.server.stdio import stdio_server
from semantic_kernel import Kernel
from semantic_kernel.functions import kernel_function
from semantic_kernel.prompt_template import InputVariable, KernelPromptTemplate, PromptTemplateConfig
logger = logging.getLogger(__name__)
"""
This sample demonstrates how to expose your Semantic Kernel `kernel` instance as a MCP server, with the a function
that uses sampling (see the docs: https://modelcontextprotocol.io/docs/concepts/sampling) to generate release notes.
To run this sample, set up your MCP host (like Claude Desktop or VSCode Github Copilot Agents)
with the following configuration:
```json
{
"mcpServers": {
"sk_release_notes": {
"command": "uv",
"args": [
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
"run",
"mcp_server_with_prompts.py"
],
}
}
}
```
Note: You might need to set the uv to it's full path.
"""
template = """{{$messages}}
---
Group the following PRs into one of these buckets for release notes, keeping the same order:
-New Features
-Enhancements and Improvements
-Bug Fixes
-Python Package Updates
Include the output in raw markdown.
"""
@kernel_function(
name="run_prompt",
description="This run the prompts for a full set of release notes based on the PR messages given.",
)
async def sampling_function(
messages: Annotated[str, "The list of PR messages, as a string with newlines"],
temperature: float = 0.0,
max_tokens: int = 1000,
# The include_in_function_choices is set to False, so it won't be included in the function choices,
# but it will get the server instance from the MCPPlugin that consumes this server.
server: Annotated[Server | None, "The server session", {"include_in_function_choices": False}] = None,
) -> str:
if not server:
raise ValueError("Request context is required for sampling function.")
sampling_response = await server.request_context.session.create_message(
messages=[
types.SamplingMessage(role="user", content=types.TextContent(type="text", text=messages)),
],
max_tokens=max_tokens,
temperature=temperature,
model_preferences=types.ModelPreferences(
hints=[types.ModelHint(name="gpt-4o-mini")],
),
)
logger.info(f"Sampling response: {sampling_response}")
return sampling_response.content.text
def run() -> None:
"""Run the MCP server with the release notes prompt template."""
kernel = Kernel()
kernel.add_function("release_notes", sampling_function)
prompt = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(
name="release_notes_prompt",
description="This creates the prompts for a full set of release notes based on the PR messages given.",
template=template,
input_variables=[
InputVariable(
name="messages",
description="These are the PR messages, they are a single string with new lines.",
is_required=True,
json_schema='{"type": "string"}',
)
],
)
)
server = kernel.as_mcp_server(server_name="sk_release_notes", prompts=[prompt])
async def handle_stdin(stdin: Any | None = None, stdout: Any | None = None) -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
anyio.run(handle_stdin)
if __name__ == "__main__":
run()
@@ -0,0 +1,227 @@
# /// script # noqa: CPY001
# dependencies = [
# "semantic-kernel[mcp]",
# ]
# ///
# Copyright (c) Microsoft. All rights reserved.
import argparse
import ipaddress
import logging
from typing import Any, Literal
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.functions import kernel_function
from semantic_kernel.prompt_template import PromptTemplateConfig
from semantic_kernel.prompt_template.input_variable import InputVariable
logger = logging.getLogger(__name__)
"""
This sample demonstrates how to expose your Semantic Kernel `kernel` instance as a MCP server.
To run this sample, set up your MCP host (like Claude Desktop or VSCode Github Copilot Agents)
with the following configuration:
```json
{
"mcpServers": {
"sk": {
"command": "uv",
"args": [
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
"run",
"sk_mcp_server.py"
],
"env": {
"OPENAI_API_KEY": "<your_openai_api_key>",
"OPENAI_CHAT_MODEL_ID": "gpt-4o-mini"
}
}
}
}
```
Note: You might need to set the uv to its full path.
Alternatively, you can run this as a SSE server, by setting the same environment variables as above,
and running the following command:
```bash
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server \
run sk_mcp_server.py --transport sse --port 8000
```
This will start a server that listens for incoming requests on port 8000.
In both cases, uv will make sure to install semantic-kernel with the mcp extra for you in a temporary venv.
"""
def is_loopback_host(host: str) -> bool:
"""Return True if the host refers to a loopback interface (incl. IPv6 ::1)."""
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def parse_arguments():
parser = argparse.ArgumentParser(description="Run the Semantic Kernel MCP server.")
parser.add_argument(
"--transport",
type=str,
choices=["sse", "stdio"],
default="stdio",
help="Transport method to use (default: stdio).",
)
parser.add_argument(
"--port",
type=int,
default=None,
help="Port to use for SSE transport (required if transport is 'sse').",
)
parser.add_argument(
"--host",
type=str,
default="127.0.0.1",
help=(
"Host/interface to bind the SSE server to (default: 127.0.0.1). "
"Binding to anything other than loopback (e.g. 0.0.0.0) exposes the server "
"to the network and should only be done on a trusted network with authentication added."
),
)
args = parser.parse_args()
if args.transport == "sse" and args.port is None:
parser.error("--port is required when --transport is 'sse'.")
return args
def run(transport: Literal["sse", "stdio"] = "stdio", port: int | None = None, host: str = "127.0.0.1") -> None:
kernel = Kernel()
@kernel_function()
def echo_function(message: str, extra: str = "") -> str:
"""Echo a message as a function"""
return f"Function echo: {message} {extra}"
kernel.add_service(OpenAIChatCompletion(service_id="default"))
kernel.add_function("echo", echo_function, "echo_function")
kernel.add_function(
plugin_name="prompt",
function_name="prompt",
prompt_template_config=PromptTemplateConfig(
name="prompt",
description="This is a prompt",
template="Please repeat this: {{$message}} and this: {{$extra}}",
input_variables=[
InputVariable(
name="message",
description="This is the message.",
is_required=True,
json_schema='{ "type": "string", "description": "This is the message."}',
),
InputVariable(
name="extra",
description="This is extra.",
default="default",
is_required=False,
json_schema='{ "type": "string", "description": "This is the message."}',
),
],
),
)
server = kernel.as_mcp_server(server_name="sk")
if transport == "sse" and port is not None:
import uvicorn
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.responses import PlainTextResponse
from starlette.routing import Mount, Route
from starlette.types import ASGIApp, Receive, Scope, Send
# A local MCP server is a security boundary, not a generic web server: it exposes
# tools, plugins and model providers backed by the developer's credentials. Without
# Host/Origin validation a malicious web page could use DNS rebinding to reach this
# loopback listener from the victim's browser and invoke the exposed MCP tools.
# The MCP spec therefore requires servers to validate Origin and bind to loopback.
allowed_hosts = [
"localhost",
"127.0.0.1",
"[::1]",
f"localhost:{port}",
f"127.0.0.1:{port}",
f"[::1]:{port}",
]
allowed_origins = {
"http://localhost",
"http://127.0.0.1",
"http://[::1]",
f"http://localhost:{port}",
f"http://127.0.0.1:{port}",
f"http://[::1]:{port}",
}
class OriginValidationMiddleware:
"""Reject requests with an untrusted Origin header (DNS-rebinding defense)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http":
origin = dict(scope["headers"]).get(b"origin")
if origin is not None:
try:
origin_value = origin.decode("ascii")
except UnicodeDecodeError:
origin_value = None
if origin_value not in allowed_origins:
response = PlainTextResponse("Forbidden: invalid Origin header", status_code=403)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
sse = SseServerTransport("/messages/")
async def handle_sse(request):
async with sse.connect_sse(request.scope, request.receive, request._send) as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
starlette_app = Starlette(
debug=False,
routes=[
Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse.handle_post_message),
],
middleware=[
Middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts),
Middleware(OriginValidationMiddleware),
],
)
if not is_loopback_host(host):
logger.warning(
"Binding the MCP SSE server to %s exposes it beyond loopback. The bundled Host/Origin "
"checks only allow loopback callers; for a network-reachable or credentialed deployment "
"add proper authentication (see the mcp_with_oauth sample) before doing this.",
host,
)
uvicorn.run(starlette_app, host=host, port=port) # nosec
elif transport == "stdio":
import anyio
from mcp.server.stdio import stdio_server
async def handle_stdin(stdin: Any | None = None, stdout: Any | None = None) -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
anyio.run(handle_stdin)
if __name__ == "__main__":
args = parse_arguments()
run(transport=args.transport, port=args.port, host=args.host)
@@ -0,0 +1,62 @@
The server code and some of the agent code for this samples comes from: https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth
in order to demonstrate connecting with OAuth to a MCP server with SK.
# MCP OAuth Authentication Demo
This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification.
---
## Running the Servers
### Step 1: Start Authorization Server
```bash
# Navigate to the simple-auth directory
cd samples/demos/mcp_with_oauth/server
# Start Authorization Server on port 9000
uv run mcp-simple-auth-as --port=9000
```
**What it provides:**
- OAuth 2.0 flows (registration, authorization, token exchange)
- Simple credential-based authentication (no external provider needed)
- Token introspection endpoint for Resource Servers (`/introspect`)
---
### Step 2: Start Resource Server (MCP Server)
```bash
# In another terminal, navigate to the simple-auth directory
cd samples/demos/mcp_with_oauth/server
# Start Resource Server on port 8001, connected to Authorization Server
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http
# With RFC 8707 strict resource validation (recommended for production)
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict
```
### Step 3: Test with Client
Either have Azure settings setup in your global venv, or add a `.env` file in `samples/demos/mcp_with_oauth/agent` with the following content:
```bash
AZURE_OPENAI_ENDPOINT=
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=
```
and then:
```bash
cd samples/demos/mcp_with_oauth
# Start agent with streamable HTTP plugin
uv --env-file .env run agent
```
or open file main.py in samples/demos/mcp_with_oauth/agent and run it in your IDE.
For more details on how the server and auth flows work, see https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth
@@ -0,0 +1,244 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import threading
import time
import webbrowser
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
from azure.identity import AzureCliCredential
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from semantic_kernel.agents import ChatCompletionAgent, ChatHistoryAgentThread
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from semantic_kernel.connectors.mcp import MCPStreamableHttpPlugin
"""
The following sample demonstrates how to setup a MCP Server that uses OAuth 2.0 for authentication.
It requires a a couple of extra classes, in order to run a server that can handle the oauth callback.
When connecting to a existing Oauth server this should be simpler.
Most of the code for the auth comes from:
https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth
The actual MCP server exposes a single tool, called get_time, which is what the input asks for.
See server/mcp_simple_auth/server.py for details.
Follow the readme in the root of this demo for instructions on how to run.
"""
# Simulate a conversation with the agent
USER_INPUTS = ["What time is it?"]
class InMemoryTokenStorage(TokenStorage):
"""Simple in-memory token storage implementation."""
def __init__(self):
self._tokens: OAuthToken | None = None
self._client_info: OAuthClientInformationFull | None = None
async def get_tokens(self) -> OAuthToken | None:
return self._tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
self._tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
return self._client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self._client_info = client_info
class CallbackHandler(BaseHTTPRequestHandler):
"""Simple HTTP handler to capture OAuth callback."""
def __init__(self, request, client_address, server, callback_data):
"""Initialize with callback data storage."""
self.callback_data = callback_data
super().__init__(request, client_address, server)
def do_GET(self):
"""Handle GET request from OAuth redirect."""
parsed = urlparse(self.path)
query_params = parse_qs(parsed.query)
if "code" in query_params:
self.callback_data["authorization_code"] = query_params["code"][0]
self.callback_data["state"] = query_params.get("state", [None])[0]
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"""
<html>
<body>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
""")
elif "error" in query_params:
self.callback_data["error"] = query_params["error"][0]
self.send_response(400)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(
f"""
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: {query_params["error"][0]}</p>
<p>You can close this window and return to the terminal.</p>
</body>
</html>
""".encode()
)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
"""Suppress default logging."""
pass
class CallbackServer:
"""Simple server to handle OAuth callbacks."""
def __init__(self, port=3000):
self.port = port
self.server = None
self.thread = None
self.callback_data = {"authorization_code": None, "state": None, "error": None}
def _create_handler_with_data(self):
"""Create a handler class with access to callback data."""
callback_data = self.callback_data
class DataCallbackHandler(CallbackHandler):
def __init__(self, request, client_address, server):
super().__init__(request, client_address, server, callback_data)
return DataCallbackHandler
def start(self):
"""Start the callback server in a background thread."""
handler_class = self._create_handler_with_data()
self.server = HTTPServer(("localhost", self.port), handler_class)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
print(f"🖥️ Started callback server on http://localhost:{self.port}")
def stop(self):
"""Stop the callback server."""
if self.server:
self.server.shutdown()
self.server.server_close()
if self.thread:
self.thread.join(timeout=1)
def wait_for_callback(self, timeout=300):
"""Wait for OAuth callback with timeout."""
start_time = time.time()
while time.time() - start_time < timeout:
if self.callback_data["authorization_code"]:
return self.callback_data["authorization_code"]
if self.callback_data["error"]:
raise Exception(f"OAuth error: {self.callback_data['error']}")
time.sleep(0.1)
raise Exception("Timeout waiting for OAuth callback")
def get_state(self):
"""Get the received state parameter."""
return self.callback_data["state"]
async def main():
# 1. Create the agent
callback_server = CallbackServer(port=3030)
callback_server.start()
async def callback_handler() -> tuple[str, str | None]:
"""Wait for OAuth callback and return auth code and state."""
print("⏳ Waiting for authorization callback...")
try:
auth_code = callback_server.wait_for_callback(timeout=300)
return auth_code, callback_server.get_state()
finally:
callback_server.stop()
client_metadata_dict = {
"client_name": "Simple Auth Client",
"redirect_uris": ["http://localhost:3030/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post",
}
async def _default_redirect_handler(authorization_url: str) -> None:
"""Default redirect handler that opens the URL in a browser."""
print(f"Opening browser for authorization: {authorization_url}")
webbrowser.open(authorization_url)
# Create OAuth authentication handler using the new interface
oauth_auth = OAuthClientProvider(
server_url="http://localhost:9000",
client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict),
storage=InMemoryTokenStorage(),
redirect_handler=_default_redirect_handler,
callback_handler=callback_handler,
)
async with MCPStreamableHttpPlugin(
name="AuthServer",
description="Auth Server Plugin",
url="http://localhost:8001/mcp",
auth=oauth_auth,
timeout=timedelta(seconds=60),
) as oath_plugin:
agent = ChatCompletionAgent(
service=AzureChatCompletion(credential=AzureCliCredential()),
name="ProtectedAgent",
instructions="Answer the users questions.",
plugins=[oath_plugin],
)
for user_input in USER_INPUTS:
# 2. Create a thread to hold the conversation
# If no thread is provided, a new thread will be
# created and returned with the initial response
thread: ChatHistoryAgentThread | None = None
print(f"# User: {user_input}")
# 3. Invoke the agent for a response
response = await agent.get_response(messages=user_input, thread=thread)
print(f"# {response.name}: {response} ")
thread = response.thread
# 4. Cleanup: Clear the thread
await thread.delete() if thread else None
"""
Expected output:
🖥️ Started callback server on http://localhost:3030
Opening browser for authorization: http://localhost:9000/authorize?response_type...
⏳ Waiting for authorization callback...
# User: What time is it?
# ProtectedAgent: The current time is 16:54:55 (4:54 PM) on July 10, 2025, in the UTC timezone.
"""
def cli():
"""Entry point for UV script."""
asyncio.run(main())
if __name__ == "__main__":
cli()
@@ -0,0 +1,37 @@
[project]
name = "sk-mcp-auth"
version = "0.1.0"
description = "A simple example of a OAuth client connection with SK."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"click>=8.0.0",
"semantic-kernel[mcp]",
]
[project.scripts]
agent = "agent.main:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["agent"]
[tool.pyright]
include = ["agent"]
venvPath = "."
venv = ".venv"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []
[tool.ruff]
line-length = 120
target-version = "py310"
[tool.uv]
dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"]
@@ -0,0 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
"""Main entry point for simple MCP server with GitHub OAuth authentication."""
import sys
from mcp_simple_auth.server import main
sys.exit(main()) # type: ignore[call-arg]
@@ -0,0 +1,188 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Authorization Server for MCP Split Demo.
This server handles OAuth flows, client registration, and token issuance.
Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc.
NOTE: this is a simplified example for demonstration purposes.
This is not a production-ready implementation.
"""
import asyncio
import logging
import time
import click
from mcp.server.auth.routes import cors_middleware, create_auth_routes
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
from pydantic import AnyHttpUrl, BaseModel
from starlette.applications import Starlette
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from uvicorn import Config, Server
from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider
logger = logging.getLogger(__name__)
class AuthServerSettings(BaseModel):
"""Settings for the Authorization Server."""
# Server settings
host: str = "localhost"
port: int = 9000
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
auth_callback_path: str = "http://localhost:9000/login/callback"
class SimpleAuthProvider(SimpleOAuthProvider):
"""
Authorization Server provider with simple demo authentication.
This provider:
1. Issues MCP tokens after simple credential authentication
2. Stores token state for introspection by Resource Servers
"""
def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str):
super().__init__(auth_settings, auth_callback_path, server_url)
def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette:
"""Create the Authorization Server application."""
oauth_provider = SimpleAuthProvider(
auth_settings, server_settings.auth_callback_path, str(server_settings.server_url)
)
mcp_auth_settings = AuthSettings(
issuer_url=server_settings.server_url,
client_registration_options=ClientRegistrationOptions(
enabled=True,
valid_scopes=[auth_settings.mcp_scope],
default_scopes=[auth_settings.mcp_scope],
),
required_scopes=[auth_settings.mcp_scope],
resource_server_url=None,
)
# Create OAuth routes
routes = create_auth_routes(
provider=oauth_provider,
issuer_url=mcp_auth_settings.issuer_url,
service_documentation_url=mcp_auth_settings.service_documentation_url,
client_registration_options=mcp_auth_settings.client_registration_options,
revocation_options=mcp_auth_settings.revocation_options,
)
# Add login page route (GET)
async def login_page_handler(request: Request) -> Response:
"""Show login form."""
state = request.query_params.get("state")
if not state:
raise HTTPException(400, "Missing state parameter")
return await oauth_provider.get_login_page(state)
routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"]))
# Add login callback route (POST)
async def login_callback_handler(request: Request) -> Response:
"""Handle simple authentication callback."""
return await oauth_provider.handle_login_callback(request)
routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"]))
# Add token introspection endpoint (RFC 7662) for Resource Servers
async def introspect_handler(request: Request) -> Response:
"""
Token introspection endpoint for Resource Servers.
Resource Servers call this endpoint to validate tokens without
needing direct access to token storage.
"""
form = await request.form()
token = form.get("token")
if not token or not isinstance(token, str):
return JSONResponse({"active": False}, status_code=400)
# Look up token in provider
access_token = await oauth_provider.load_access_token(token)
if not access_token:
return JSONResponse({"active": False})
return JSONResponse(
{
"active": True,
"client_id": access_token.client_id,
"scope": " ".join(access_token.scopes),
"exp": access_token.expires_at,
"iat": int(time.time()),
"token_type": "Bearer",
"aud": access_token.resource, # RFC 8707 audience claim
}
)
routes.append(
Route(
"/introspect",
endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]),
methods=["POST", "OPTIONS"],
)
)
return Starlette(routes=routes)
async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings):
"""Run the Authorization Server."""
auth_server = create_authorization_server(server_settings, auth_settings)
config = Config(
auth_server,
host=server_settings.host,
port=server_settings.port,
log_level="info",
)
server = Server(config)
logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}")
await server.serve()
@click.command()
@click.option("--port", default=9000, help="Port to listen on")
def main(port: int) -> int:
"""
Run the MCP Authorization Server.
This server handles OAuth flows and can be used by multiple Resource Servers.
Uses simple hardcoded credentials for demo purposes.
"""
logging.basicConfig(level=logging.INFO)
# Load simple auth settings
auth_settings = SimpleAuthSettings()
# Create server settings
host = "localhost"
server_url = f"http://{host}:{port}"
server_settings = AuthServerSettings(
host=host,
port=port,
server_url=AnyHttpUrl(server_url),
auth_callback_path=f"{server_url}/login",
)
asyncio.run(run_server(server_settings, auth_settings))
return 0
if __name__ == "__main__":
main() # type: ignore[call-arg]
@@ -0,0 +1,140 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Legacy Combined Authorization Server + Resource Server for MCP.
This server implements the old spec where MCP servers could act as both AS and RS.
Used for backwards compatibility testing with the new split AS/RS architecture.
NOTE: this is a simplified example for demonstration purposes.
This is not a production-ready implementation.
"""
import datetime
import logging
from typing import Any, Literal
import click
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
from mcp.server.fastmcp.server import FastMCP
from pydantic import AnyHttpUrl, BaseModel
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import Response
from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider
logger = logging.getLogger(__name__)
class ServerSettings(BaseModel):
"""Settings for the simple auth MCP server."""
# Server settings
host: str = "localhost"
port: int = 8000
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
auth_callback_path: str = "http://localhost:8000/login/callback"
class LegacySimpleOAuthProvider(SimpleOAuthProvider):
"""Simple OAuth provider for legacy MCP server."""
def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str):
super().__init__(auth_settings, auth_callback_path, server_url)
def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> FastMCP:
"""Create a simple FastMCP server with simple authentication."""
oauth_provider = LegacySimpleOAuthProvider(
auth_settings, server_settings.auth_callback_path, str(server_settings.server_url)
)
mcp_auth_settings = AuthSettings(
issuer_url=server_settings.server_url,
client_registration_options=ClientRegistrationOptions(
enabled=True,
valid_scopes=[auth_settings.mcp_scope],
default_scopes=[auth_settings.mcp_scope],
),
required_scopes=[auth_settings.mcp_scope],
# No resource_server_url parameter in legacy mode
resource_server_url=None,
)
app = FastMCP(
name="Simple Auth MCP Server",
instructions="A simple MCP server with simple credential authentication",
auth_server_provider=oauth_provider,
host=server_settings.host,
port=server_settings.port,
debug=True,
auth=mcp_auth_settings,
)
@app.custom_route("/login", methods=["GET"])
async def login_page_handler(request: Request) -> Response:
"""Show login form."""
state = request.query_params.get("state")
if not state:
raise HTTPException(400, "Missing state parameter")
return await oauth_provider.get_login_page(state)
@app.custom_route("/login/callback", methods=["POST"])
async def login_callback_handler(request: Request) -> Response:
"""Handle simple authentication callback."""
return await oauth_provider.handle_login_callback(request)
@app.tool()
async def get_time() -> dict[str, Any]:
"""
Get the current server time.
This tool demonstrates that system information can be protected
by OAuth authentication. User must be authenticated to access it.
"""
now = datetime.datetime.now()
return {
"current_time": now.isoformat(),
"timezone": "UTC", # Simplified for demo
"timestamp": now.timestamp(),
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
}
return app
@click.command()
@click.option("--port", default=8000, help="Port to listen on")
@click.option(
"--transport",
default="streamable-http",
type=click.Choice(["sse", "streamable-http"]),
help="Transport protocol to use ('sse' or 'streamable-http')",
)
def main(port: int, transport: Literal["sse", "streamable-http"]) -> int:
"""Run the simple auth MCP server."""
logging.basicConfig(level=logging.INFO)
auth_settings = SimpleAuthSettings()
# Create server settings
host = "localhost"
server_url = f"http://{host}:{port}"
server_settings = ServerSettings(
host=host,
port=port,
server_url=AnyHttpUrl(server_url),
auth_callback_path=f"{server_url}/login",
)
mcp_server = create_simple_mcp_server(server_settings, auth_settings)
logger.info(f"🚀 MCP Legacy Server running on {server_url}")
mcp_server.run(transport=transport)
return 0
if __name__ == "__main__":
main() # type: ignore[call-arg]
@@ -0,0 +1,170 @@
# Copyright (c) Microsoft. All rights reserved.
"""
MCP Resource Server with Token Introspection.
This server validates tokens via Authorization Server introspection and serves MCP resources.
Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation.
NOTE: this is a simplified example for demonstration purposes.
This is not a production-ready implementation.
"""
import datetime
import logging
from typing import Any, Literal
import click
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp.server import FastMCP
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from .token_verifier import IntrospectionTokenVerifier
logger = logging.getLogger(__name__)
class ResourceServerSettings(BaseSettings):
"""Settings for the MCP Resource Server."""
model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_")
# Server settings
host: str = "localhost"
port: int = 8001
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001")
# Authorization Server settings
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
auth_server_introspection_endpoint: str = "http://localhost:9000/introspect"
# No user endpoint needed - we get user data from token introspection
# MCP settings
mcp_scope: str = "user"
# RFC 8707 resource validation
oauth_strict: bool = False
def __init__(self, **data):
"""Initialize settings with values from environment variables."""
super().__init__(**data)
def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
"""
Create MCP Resource Server with token introspection.
This server:
1. Provides protected resource metadata (RFC 9728)
2. Validates tokens via Authorization Server introspection
3. Serves MCP tools and resources
"""
# Create token verifier for introspection with RFC 8707 resource validation
token_verifier = IntrospectionTokenVerifier(
introspection_endpoint=settings.auth_server_introspection_endpoint,
server_url=str(settings.server_url),
validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set
)
# Create FastMCP server as a Resource Server
app = FastMCP(
name="MCP Resource Server",
instructions="Resource Server that validates tokens via Authorization Server introspection",
host=settings.host,
port=settings.port,
debug=True,
# Auth configuration for RS mode
token_verifier=token_verifier,
auth=AuthSettings(
issuer_url=settings.auth_server_url,
required_scopes=[settings.mcp_scope],
resource_server_url=settings.server_url,
),
)
@app.tool()
async def get_time() -> dict[str, Any]:
"""
Get the current server time.
This tool demonstrates that system information can be protected
by OAuth authentication. User must be authenticated to access it.
"""
now = datetime.datetime.now()
return {
"current_time": now.isoformat(),
"timezone": "UTC", # Simplified for demo
"timestamp": now.timestamp(),
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
}
return app
@click.command()
@click.option("--port", default=8001, help="Port to listen on")
@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL")
@click.option(
"--transport",
default="streamable-http",
type=click.Choice(["sse", "streamable-http"]),
help="Transport protocol to use ('sse' or 'streamable-http')",
)
@click.option(
"--oauth-strict",
is_flag=True,
help="Enable RFC 8707 resource validation",
)
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int:
"""
Run the MCP Resource Server.
This server:
- Provides RFC 9728 Protected Resource Metadata
- Validates tokens via Authorization Server introspection
- Serves MCP tools requiring authentication
Must be used with a running Authorization Server.
"""
logging.basicConfig(level=logging.INFO)
try:
# Parse auth server URL
auth_server_url = AnyHttpUrl(auth_server)
# Create settings
host = "localhost"
server_url = f"http://{host}:{port}"
settings = ResourceServerSettings(
host=host,
port=port,
server_url=AnyHttpUrl(server_url),
auth_server_url=auth_server_url,
auth_server_introspection_endpoint=f"{auth_server}/introspect",
oauth_strict=oauth_strict,
)
except ValueError as e:
logger.error(f"Configuration error: {e}")
logger.error("Make sure to provide a valid Authorization Server URL")
return 1
try:
mcp_server = create_resource_server(settings)
logger.info(f"🚀 MCP Resource Server running on {settings.server_url}")
logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}")
# Run the server - this should block and keep running
mcp_server.run(transport=transport)
logger.info("Server stopped")
return 0
except Exception:
logger.exception("Server error")
return 1
if __name__ == "__main__":
main() # type: ignore[call-arg]
@@ -0,0 +1,271 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Simple OAuth provider for MCP servers.
This module contains a basic OAuth implementation using hardcoded user credentials
for demonstration purposes. No external authentication provider is required.
NOTE: this is a simplified example for demonstration purposes.
This is not a production-ready implementation.
"""
import logging
import secrets
import time
from typing import Any
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
OAuthAuthorizationServerProvider,
RefreshToken,
construct_redirect_uri,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse, Response
logger = logging.getLogger(__name__)
class SimpleAuthSettings(BaseSettings):
"""Simple OAuth settings for demo purposes."""
model_config = SettingsConfigDict(env_prefix="MCP_")
# Demo user credentials
demo_username: str = "demo_user"
demo_password: str = "demo_password"
# MCP OAuth scope
mcp_scope: str = "user"
class SimpleOAuthProvider(OAuthAuthorizationServerProvider):
"""
Simple OAuth provider for demo purposes.
This provider handles the OAuth flow by:
1. Providing a simple login form for demo credentials
2. Issuing MCP tokens after successful authentication
3. Maintaining token state for introspection
"""
def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str):
self.settings = settings
self.auth_callback_url = auth_callback_url
self.server_url = server_url
self.clients: dict[str, OAuthClientInformationFull] = {}
self.auth_codes: dict[str, AuthorizationCode] = {}
self.tokens: dict[str, AccessToken] = {}
self.state_mapping: dict[str, dict[str, str | None]] = {}
# Store authenticated user information
self.user_data: dict[str, dict[str, Any]] = {}
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
"""Get OAuth client information."""
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull):
"""Register a new OAuth client."""
self.clients[client_info.client_id] = client_info
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
"""Generate an authorization URL for simple login flow."""
state = params.state or secrets.token_hex(16)
# Store state mapping for callback
self.state_mapping[state] = {
"redirect_uri": str(params.redirect_uri),
"code_challenge": params.code_challenge,
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
"client_id": client.client_id,
"resource": params.resource, # RFC 8707
}
# Build simple login URL that points to login page
auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}"
return auth_url
async def get_login_page(self, state: str) -> HTMLResponse:
"""Generate login page HTML for the given state."""
if not state:
raise HTTPException(400, "Missing state parameter")
# Create simple login form HTML
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>MCP Demo Authentication</title>
<style>
body {{ font-family: Arial, sans-serif; max-width: 500px; margin: 0 auto; padding: 20px; }}
.form-group {{ margin-bottom: 15px; }}
input {{ width: 100%; padding: 8px; margin-top: 5px; }}
button {{ background-color: #4CAF50; color: white; padding: 10px 15px; border: none; cursor: pointer; }}
</style>
</head>
<body>
<h2>MCP Demo Authentication</h2>
<p>This is a simplified authentication demo. Use the demo credentials below:</p>
<p><strong>Username:</strong> demo_user<br>
<strong>Password:</strong> demo_password</p>
<form action="{self.server_url.rstrip("/")}/login/callback" method="post">
<input type="hidden" name="state" value="{state}">
<div class="form-group">
<label>Username:</label>
<input type="text" name="username" value="demo_user" required>
</div>
<div class="form-group">
<label>Password:</label>
<input type="password" name="password" value="demo_password" required>
</div>
<button type="submit">Sign In</button>
</form>
</body>
</html>
"""
return HTMLResponse(content=html_content)
async def handle_login_callback(self, request: Request) -> Response:
"""Handle login form submission callback."""
form = await request.form()
username = form.get("username")
password = form.get("password")
state = form.get("state")
if not username or not password or not state:
raise HTTPException(400, "Missing username, password, or state parameter")
# Ensure we have strings, not UploadFile objects
if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str):
raise HTTPException(400, "Invalid parameter types")
redirect_uri = await self.handle_simple_callback(username, password, state)
return RedirectResponse(url=redirect_uri, status_code=302)
async def handle_simple_callback(self, username: str, password: str, state: str) -> str:
"""Handle simple authentication callback and return redirect URI."""
state_data = self.state_mapping.get(state)
if not state_data:
raise HTTPException(400, "Invalid state parameter")
redirect_uri = state_data["redirect_uri"]
code_challenge = state_data["code_challenge"]
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
client_id = state_data["client_id"]
resource = state_data.get("resource") # RFC 8707
# These are required values from our own state mapping
assert redirect_uri is not None
assert code_challenge is not None
assert client_id is not None
# Validate demo credentials
if username != self.settings.demo_username or password != self.settings.demo_password:
raise HTTPException(401, "Invalid credentials")
# Create MCP authorization code
new_code = f"mcp_{secrets.token_hex(16)}"
auth_code = AuthorizationCode(
code=new_code,
client_id=client_id,
redirect_uri=AnyHttpUrl(redirect_uri),
redirect_uri_provided_explicitly=redirect_uri_provided_explicitly,
expires_at=time.time() + 300,
scopes=[self.settings.mcp_scope],
code_challenge=code_challenge,
resource=resource, # RFC 8707
)
self.auth_codes[new_code] = auth_code
# Store user data
self.user_data[username] = {
"username": username,
"user_id": f"user_{secrets.token_hex(8)}",
"authenticated_at": time.time(),
}
del self.state_mapping[state]
return construct_redirect_uri(redirect_uri, code=new_code, state=state)
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
"""Load an authorization code."""
return self.auth_codes.get(authorization_code)
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
"""Exchange authorization code for tokens."""
if authorization_code.code not in self.auth_codes:
raise ValueError("Invalid authorization code")
# Generate MCP access token
mcp_token = f"mcp_{secrets.token_hex(32)}"
# Store MCP token
self.tokens[mcp_token] = AccessToken(
token=mcp_token,
client_id=client.client_id,
scopes=authorization_code.scopes,
expires_at=int(time.time()) + 3600,
resource=authorization_code.resource, # RFC 8707
)
# Store user data mapping for this token
self.user_data[mcp_token] = {
"username": self.settings.demo_username,
"user_id": f"user_{secrets.token_hex(8)}",
"authenticated_at": time.time(),
}
del self.auth_codes[authorization_code.code]
return OAuthToken(
access_token=mcp_token,
token_type="Bearer",
expires_in=3600,
scope=" ".join(authorization_code.scopes),
)
async def load_access_token(self, token: str) -> AccessToken | None:
"""Load and validate an access token."""
access_token = self.tokens.get(token)
if not access_token:
return None
# Check if expired
if access_token.expires_at and access_token.expires_at < time.time():
del self.tokens[token]
return None
return access_token
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
"""Load a refresh token - not supported in this example."""
return None
async def exchange_refresh_token(
self,
client: OAuthClientInformationFull,
refresh_token: RefreshToken,
scopes: list[str],
) -> OAuthToken:
"""Exchange refresh token - not supported in this example."""
raise NotImplementedError("Refresh tokens not supported")
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None:
"""Revoke a token."""
if token in self.tokens:
del self.tokens[token]
@@ -0,0 +1,107 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
import logging
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
logger = logging.getLogger(__name__)
class IntrospectionTokenVerifier(TokenVerifier):
"""Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662).
This is a simple example implementation for demonstration purposes.
Production implementations should consider:
- Connection pooling and reuse
- More sophisticated error handling
- Rate limiting and retry logic
- Comprehensive configuration options
"""
def __init__(
self,
introspection_endpoint: str,
server_url: str,
validate_resource: bool = False,
):
self.introspection_endpoint = introspection_endpoint
self.server_url = server_url
self.validate_resource = validate_resource
self.resource_url = resource_url_from_server_url(server_url)
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify token via introspection endpoint."""
import httpx
# Validate URL to prevent SSRF attacks
if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}")
return None
# Configure secure HTTP client
timeout = httpx.Timeout(10.0, connect=5.0)
limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
async with httpx.AsyncClient(
timeout=timeout,
limits=limits,
verify=True, # Enforce SSL verification
) as client:
try:
response = await client.post(
self.introspection_endpoint,
data={"token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code != 200:
logger.debug(f"Token introspection returned status {response.status_code}")
return None
data = response.json()
if not data.get("active", False):
return None
# RFC 8707 resource validation (only when --oauth-strict is set)
if self.validate_resource and not self._validate_resource(data):
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
return None
return AccessToken(
token=token,
client_id=data.get("client_id", "unknown"),
scopes=data.get("scope", "").split() if data.get("scope") else [],
expires_at=data.get("exp"),
resource=data.get("aud"), # Include resource in token
)
except Exception as e:
logger.warning(f"Token introspection failed: {e}")
return None
def _validate_resource(self, token_data: dict) -> bool:
"""Validate token was issued for this resource server."""
if not self.server_url or not self.resource_url:
return False # Fail if strict validation requested but URLs missing
# Check 'aud' claim first (standard JWT audience)
aud = token_data.get("aud")
if isinstance(aud, list):
for audience in aud:
if self._is_valid_resource(audience):
return True
return False
elif aud:
return self._is_valid_resource(aud)
# No resource binding - invalid per RFC 8707
return False
def _is_valid_resource(self, resource: str) -> bool:
"""Check if resource matches this server using hierarchical matching."""
if not self.resource_url:
return False
return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource)
@@ -0,0 +1,33 @@
[project]
name = "mcp-simple-auth"
version = "0.1.0"
description = "A simple MCP server demonstrating OAuth authentication: source: https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
license = { text = "MIT" }
dependencies = [
"anyio>=4.5",
"click>=8.1.0",
"httpx>=0.27",
"mcp",
"pydantic>=2.0",
"pydantic-settings>=2.5.2",
"sse-starlette>=1.6.1",
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
]
[project.scripts]
mcp-simple-auth-rs = "mcp_simple_auth.server:main"
mcp-simple-auth-as = "mcp_simple_auth.auth_server:main"
mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_auth"]
[tool.uv]
dev-dependencies = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]
+705
View File
@@ -0,0 +1,705 @@
version = 1
revision = 2
requires-python = ">=3.10"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
[[package]]
name = "attrs"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
]
[[package]]
name = "certifi"
version = "2025.7.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "jsonschema"
version = "4.24.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196", size = 353480, upload-time = "2025-05-26T18:48:10.459Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d", size = 88709, upload-time = "2025-05-26T18:48:08.417Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
]
[[package]]
name = "mcp"
version = "1.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/68/63045305f29ff680a9cd5be360c755270109e6b76f696ea6824547ddbc30/mcp-1.10.1.tar.gz", hash = "sha256:aaa0957d8307feeff180da2d9d359f2b801f35c0c67f1882136239055ef034c2", size = 392969, upload-time = "2025-06-27T12:03:08.982Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/3f/435a5b3d10ae242a9d6c2b33175551173c3c61fe637dc893be05c4ed0aaf/mcp-1.10.1-py3-none-any.whl", hash = "sha256:4d08301aefe906dce0fa482289db55ce1db831e3e67212e65b5e23ad8454b3c5", size = 150878, upload-time = "2025-06-27T12:03:07.328Z" },
]
[[package]]
name = "mcp-simple-auth"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "anyio" },
{ name = "click" },
{ name = "httpx" },
{ name = "mcp" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "sse-starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = ">=4.5" },
{ name = "click", specifier = ">=8.1.0" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "mcp" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pydantic-settings", specifier = ">=2.5.2" },
{ name = "sse-starlette", specifier = ">=1.6.1" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.391" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "ruff", specifier = ">=0.8.5" },
]
[[package]]
name = "nodeenv"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" },
{ url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" },
{ url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" },
{ url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" },
{ url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" },
{ url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" },
{ url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" },
{ url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
{ url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" },
{ url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" },
{ url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" },
{ url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" },
{ url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" },
{ url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" },
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyright"
version = "1.1.403"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" },
]
[[package]]
name = "pytest"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "referencing"
version = "0.36.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" },
]
[[package]]
name = "rpds-py"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/aa/4456d84bbb54adc6a916fb10c9b374f78ac840337644e4a5eda229c81275/rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0", size = 27385, upload-time = "2025-07-01T15:57:13.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/31/1459645f036c3dfeacef89e8e5825e430c77dde8489f3b99eaafcd4a60f5/rpds_py-0.26.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4c70c70f9169692b36307a95f3d8c0a9fcd79f7b4a383aad5eaa0e9718b79b37", size = 372466, upload-time = "2025-07-01T15:53:40.55Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ff/3d0727f35836cc8773d3eeb9a46c40cc405854e36a8d2e951f3a8391c976/rpds_py-0.26.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:777c62479d12395bfb932944e61e915741e364c843afc3196b694db3d669fcd0", size = 357825, upload-time = "2025-07-01T15:53:42.247Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ce/badc5e06120a54099ae287fa96d82cbb650a5f85cf247ffe19c7b157fd1f/rpds_py-0.26.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec671691e72dff75817386aa02d81e708b5a7ec0dec6669ec05213ff6b77e1bd", size = 381530, upload-time = "2025-07-01T15:53:43.585Z" },
{ url = "https://files.pythonhosted.org/packages/1e/a5/fa5d96a66c95d06c62d7a30707b6a4cfec696ab8ae280ee7be14e961e118/rpds_py-0.26.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a1cb5d6ce81379401bbb7f6dbe3d56de537fb8235979843f0d53bc2e9815a79", size = 396933, upload-time = "2025-07-01T15:53:45.78Z" },
{ url = "https://files.pythonhosted.org/packages/00/a7/7049d66750f18605c591a9db47d4a059e112a0c9ff8de8daf8fa0f446bba/rpds_py-0.26.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f789e32fa1fb6a7bf890e0124e7b42d1e60d28ebff57fe806719abb75f0e9a3", size = 513973, upload-time = "2025-07-01T15:53:47.085Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f1/528d02c7d6b29d29fac8fd784b354d3571cc2153f33f842599ef0cf20dd2/rpds_py-0.26.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c55b0a669976cf258afd718de3d9ad1b7d1fe0a91cd1ab36f38b03d4d4aeaaf", size = 402293, upload-time = "2025-07-01T15:53:48.117Z" },
{ url = "https://files.pythonhosted.org/packages/15/93/fde36cd6e4685df2cd08508f6c45a841e82f5bb98c8d5ecf05649522acb5/rpds_py-0.26.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70d9ec912802ecfd6cd390dadb34a9578b04f9bcb8e863d0a7598ba5e9e7ccc", size = 383787, upload-time = "2025-07-01T15:53:50.874Z" },
{ url = "https://files.pythonhosted.org/packages/69/f2/5007553aaba1dcae5d663143683c3dfd03d9395289f495f0aebc93e90f24/rpds_py-0.26.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3021933c2cb7def39d927b9862292e0f4c75a13d7de70eb0ab06efed4c508c19", size = 416312, upload-time = "2025-07-01T15:53:52.046Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a7/ce52c75c1e624a79e48a69e611f1c08844564e44c85db2b6f711d76d10ce/rpds_py-0.26.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a7898b6ca3b7d6659e55cdac825a2e58c638cbf335cde41f4619e290dd0ad11", size = 558403, upload-time = "2025-07-01T15:53:53.192Z" },
{ url = "https://files.pythonhosted.org/packages/79/d5/e119db99341cc75b538bf4cb80504129fa22ce216672fb2c28e4a101f4d9/rpds_py-0.26.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:12bff2ad9447188377f1b2794772f91fe68bb4bbfa5a39d7941fbebdbf8c500f", size = 588323, upload-time = "2025-07-01T15:53:54.336Z" },
{ url = "https://files.pythonhosted.org/packages/93/94/d28272a0b02f5fe24c78c20e13bbcb95f03dc1451b68e7830ca040c60bd6/rpds_py-0.26.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:191aa858f7d4902e975d4cf2f2d9243816c91e9605070aeb09c0a800d187e323", size = 554541, upload-time = "2025-07-01T15:53:55.469Z" },
{ url = "https://files.pythonhosted.org/packages/93/e0/8c41166602f1b791da892d976057eba30685486d2e2c061ce234679c922b/rpds_py-0.26.0-cp310-cp310-win32.whl", hash = "sha256:b37a04d9f52cb76b6b78f35109b513f6519efb481d8ca4c321f6a3b9580b3f45", size = 220442, upload-time = "2025-07-01T15:53:56.524Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/509736bb752a7ab50fb0270c2a4134d671a7b3038030837e5536c3de0e0b/rpds_py-0.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:38721d4c9edd3eb6670437d8d5e2070063f305bfa2d5aa4278c51cedcd508a84", size = 231314, upload-time = "2025-07-01T15:53:57.842Z" },
{ url = "https://files.pythonhosted.org/packages/09/4c/4ee8f7e512030ff79fda1df3243c88d70fc874634e2dbe5df13ba4210078/rpds_py-0.26.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9e8cb77286025bdb21be2941d64ac6ca016130bfdcd228739e8ab137eb4406ed", size = 372610, upload-time = "2025-07-01T15:53:58.844Z" },
{ url = "https://files.pythonhosted.org/packages/fa/9d/3dc16be00f14fc1f03c71b1d67c8df98263ab2710a2fbd65a6193214a527/rpds_py-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e09330b21d98adc8ccb2dbb9fc6cb434e8908d4c119aeaa772cb1caab5440a0", size = 358032, upload-time = "2025-07-01T15:53:59.985Z" },
{ url = "https://files.pythonhosted.org/packages/e7/5a/7f1bf8f045da2866324a08ae80af63e64e7bfaf83bd31f865a7b91a58601/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9c1b92b774b2e68d11193dc39620d62fd8ab33f0a3c77ecdabe19c179cdbc1", size = 381525, upload-time = "2025-07-01T15:54:01.162Z" },
{ url = "https://files.pythonhosted.org/packages/45/8a/04479398c755a066ace10e3d158866beb600867cacae194c50ffa783abd0/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:824e6d3503ab990d7090768e4dfd9e840837bae057f212ff9f4f05ec6d1975e7", size = 397089, upload-time = "2025-07-01T15:54:02.319Z" },
{ url = "https://files.pythonhosted.org/packages/72/88/9203f47268db488a1b6d469d69c12201ede776bb728b9d9f29dbfd7df406/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ad7fd2258228bf288f2331f0a6148ad0186b2e3643055ed0db30990e59817a6", size = 514255, upload-time = "2025-07-01T15:54:03.38Z" },
{ url = "https://files.pythonhosted.org/packages/f5/b4/01ce5d1e853ddf81fbbd4311ab1eff0b3cf162d559288d10fd127e2588b5/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dc23bbb3e06ec1ea72d515fb572c1fea59695aefbffb106501138762e1e915e", size = 402283, upload-time = "2025-07-01T15:54:04.923Z" },
{ url = "https://files.pythonhosted.org/packages/34/a2/004c99936997bfc644d590a9defd9e9c93f8286568f9c16cdaf3e14429a7/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80bf832ac7b1920ee29a426cdca335f96a2b5caa839811803e999b41ba9030d", size = 383881, upload-time = "2025-07-01T15:54:06.482Z" },
{ url = "https://files.pythonhosted.org/packages/05/1b/ef5fba4a8f81ce04c427bfd96223f92f05e6cd72291ce9d7523db3b03a6c/rpds_py-0.26.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0919f38f5542c0a87e7b4afcafab6fd2c15386632d249e9a087498571250abe3", size = 415822, upload-time = "2025-07-01T15:54:07.605Z" },
{ url = "https://files.pythonhosted.org/packages/16/80/5c54195aec456b292f7bd8aa61741c8232964063fd8a75fdde9c1e982328/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d422b945683e409000c888e384546dbab9009bb92f7c0b456e217988cf316107", size = 558347, upload-time = "2025-07-01T15:54:08.591Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1c/1845c1b1fd6d827187c43afe1841d91678d7241cbdb5420a4c6de180a538/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a7711fa562ba2da1aa757e11024ad6d93bad6ad7ede5afb9af144623e5f76a", size = 587956, upload-time = "2025-07-01T15:54:09.963Z" },
{ url = "https://files.pythonhosted.org/packages/2e/ff/9e979329dd131aa73a438c077252ddabd7df6d1a7ad7b9aacf6261f10faa/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238e8c8610cb7c29460e37184f6799547f7e09e6a9bdbdab4e8edb90986a2318", size = 554363, upload-time = "2025-07-01T15:54:11.073Z" },
{ url = "https://files.pythonhosted.org/packages/00/8b/d78cfe034b71ffbe72873a136e71acc7a831a03e37771cfe59f33f6de8a2/rpds_py-0.26.0-cp311-cp311-win32.whl", hash = "sha256:893b022bfbdf26d7bedb083efeea624e8550ca6eb98bf7fea30211ce95b9201a", size = 220123, upload-time = "2025-07-01T15:54:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/94/c1/3c8c94c7dd3905dbfde768381ce98778500a80db9924731d87ddcdb117e9/rpds_py-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:87a5531de9f71aceb8af041d72fc4cab4943648d91875ed56d2e629bef6d4c03", size = 231732, upload-time = "2025-07-01T15:54:13.434Z" },
{ url = "https://files.pythonhosted.org/packages/67/93/e936fbed1b734eabf36ccb5d93c6a2e9246fbb13c1da011624b7286fae3e/rpds_py-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:de2713f48c1ad57f89ac25b3cb7daed2156d8e822cf0eca9b96a6f990718cc41", size = 221917, upload-time = "2025-07-01T15:54:14.559Z" },
{ url = "https://files.pythonhosted.org/packages/ea/86/90eb87c6f87085868bd077c7a9938006eb1ce19ed4d06944a90d3560fce2/rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d", size = 363933, upload-time = "2025-07-01T15:54:15.734Z" },
{ url = "https://files.pythonhosted.org/packages/63/78/4469f24d34636242c924626082b9586f064ada0b5dbb1e9d096ee7a8e0c6/rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136", size = 350447, upload-time = "2025-07-01T15:54:16.922Z" },
{ url = "https://files.pythonhosted.org/packages/ad/91/c448ed45efdfdade82348d5e7995e15612754826ea640afc20915119734f/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582", size = 384711, upload-time = "2025-07-01T15:54:18.101Z" },
{ url = "https://files.pythonhosted.org/packages/ec/43/e5c86fef4be7f49828bdd4ecc8931f0287b1152c0bb0163049b3218740e7/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e", size = 400865, upload-time = "2025-07-01T15:54:19.295Z" },
{ url = "https://files.pythonhosted.org/packages/55/34/e00f726a4d44f22d5c5fe2e5ddd3ac3d7fd3f74a175607781fbdd06fe375/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15", size = 517763, upload-time = "2025-07-01T15:54:20.858Z" },
{ url = "https://files.pythonhosted.org/packages/52/1c/52dc20c31b147af724b16104500fba13e60123ea0334beba7b40e33354b4/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8", size = 406651, upload-time = "2025-07-01T15:54:22.508Z" },
{ url = "https://files.pythonhosted.org/packages/2e/77/87d7bfabfc4e821caa35481a2ff6ae0b73e6a391bb6b343db2c91c2b9844/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a", size = 386079, upload-time = "2025-07-01T15:54:23.987Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d4/7f2200c2d3ee145b65b3cddc4310d51f7da6a26634f3ac87125fd789152a/rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323", size = 421379, upload-time = "2025-07-01T15:54:25.073Z" },
{ url = "https://files.pythonhosted.org/packages/ae/13/9fdd428b9c820869924ab62236b8688b122baa22d23efdd1c566938a39ba/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158", size = 562033, upload-time = "2025-07-01T15:54:26.225Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e1/b69686c3bcbe775abac3a4c1c30a164a2076d28df7926041f6c0eb5e8d28/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3", size = 591639, upload-time = "2025-07-01T15:54:27.424Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c9/1e3d8c8863c84a90197ac577bbc3d796a92502124c27092413426f670990/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2", size = 557105, upload-time = "2025-07-01T15:54:29.93Z" },
{ url = "https://files.pythonhosted.org/packages/9f/c5/90c569649057622959f6dcc40f7b516539608a414dfd54b8d77e3b201ac0/rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44", size = 223272, upload-time = "2025-07-01T15:54:31.128Z" },
{ url = "https://files.pythonhosted.org/packages/7d/16/19f5d9f2a556cfed454eebe4d354c38d51c20f3db69e7b4ce6cff904905d/rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c", size = 234995, upload-time = "2025-07-01T15:54:32.195Z" },
{ url = "https://files.pythonhosted.org/packages/83/f0/7935e40b529c0e752dfaa7880224771b51175fce08b41ab4a92eb2fbdc7f/rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8", size = 223198, upload-time = "2025-07-01T15:54:33.271Z" },
{ url = "https://files.pythonhosted.org/packages/6a/67/bb62d0109493b12b1c6ab00de7a5566aa84c0e44217c2d94bee1bd370da9/rpds_py-0.26.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:696764a5be111b036256c0b18cd29783fab22154690fc698062fc1b0084b511d", size = 363917, upload-time = "2025-07-01T15:54:34.755Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f3/34e6ae1925a5706c0f002a8d2d7f172373b855768149796af87bd65dcdb9/rpds_py-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6c15d2080a63aaed876e228efe4f814bc7889c63b1e112ad46fdc8b368b9e1", size = 350073, upload-time = "2025-07-01T15:54:36.292Z" },
{ url = "https://files.pythonhosted.org/packages/75/83/1953a9d4f4e4de7fd0533733e041c28135f3c21485faaef56a8aadbd96b5/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390e3170babf42462739a93321e657444f0862c6d722a291accc46f9d21ed04e", size = 384214, upload-time = "2025-07-01T15:54:37.469Z" },
{ url = "https://files.pythonhosted.org/packages/48/0e/983ed1b792b3322ea1d065e67f4b230f3b96025f5ce3878cc40af09b7533/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7da84c2c74c0f5bc97d853d9e17bb83e2dcafcff0dc48286916001cc114379a1", size = 400113, upload-time = "2025-07-01T15:54:38.954Z" },
{ url = "https://files.pythonhosted.org/packages/69/7f/36c0925fff6f660a80be259c5b4f5e53a16851f946eb080351d057698528/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c5fe114a6dd480a510b6d3661d09d67d1622c4bf20660a474507aaee7eeeee9", size = 515189, upload-time = "2025-07-01T15:54:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/13/45/cbf07fc03ba7a9b54662c9badb58294ecfb24f828b9732970bd1a431ed5c/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3100b3090269f3a7ea727b06a6080d4eb7439dca4c0e91a07c5d133bb1727ea7", size = 406998, upload-time = "2025-07-01T15:54:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/6c/b0/8fa5e36e58657997873fd6a1cf621285ca822ca75b4b3434ead047daa307/rpds_py-0.26.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c03c9b0c64afd0320ae57de4c982801271c0c211aa2d37f3003ff5feb75bb04", size = 385903, upload-time = "2025-07-01T15:54:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f7/b25437772f9f57d7a9fbd73ed86d0dcd76b4c7c6998348c070d90f23e315/rpds_py-0.26.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5963b72ccd199ade6ee493723d18a3f21ba7d5b957017607f815788cef50eaf1", size = 419785, upload-time = "2025-07-01T15:54:46.043Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6b/63ffa55743dfcb4baf2e9e77a0b11f7f97ed96a54558fcb5717a4b2cd732/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da4e873860ad5bab3291438525cae80169daecbfafe5657f7f5fb4d6b3f96b9", size = 561329, upload-time = "2025-07-01T15:54:47.64Z" },
{ url = "https://files.pythonhosted.org/packages/2f/07/1f4f5e2886c480a2346b1e6759c00278b8a69e697ae952d82ae2e6ee5db0/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5afaddaa8e8c7f1f7b4c5c725c0070b6eed0228f705b90a1732a48e84350f4e9", size = 590875, upload-time = "2025-07-01T15:54:48.9Z" },
{ url = "https://files.pythonhosted.org/packages/cc/bc/e6639f1b91c3a55f8c41b47d73e6307051b6e246254a827ede730624c0f8/rpds_py-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4916dc96489616a6f9667e7526af8fa693c0fdb4f3acb0e5d9f4400eb06a47ba", size = 556636, upload-time = "2025-07-01T15:54:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/05/4c/b3917c45566f9f9a209d38d9b54a1833f2bb1032a3e04c66f75726f28876/rpds_py-0.26.0-cp313-cp313-win32.whl", hash = "sha256:2a343f91b17097c546b93f7999976fd6c9d5900617aa848c81d794e062ab302b", size = 222663, upload-time = "2025-07-01T15:54:52.023Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0b/0851bdd6025775aaa2365bb8de0697ee2558184c800bfef8d7aef5ccde58/rpds_py-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a0b60701f2300c81b2ac88a5fb893ccfa408e1c4a555a77f908a2596eb875a5", size = 234428, upload-time = "2025-07-01T15:54:53.692Z" },
{ url = "https://files.pythonhosted.org/packages/ed/e8/a47c64ed53149c75fb581e14a237b7b7cd18217e969c30d474d335105622/rpds_py-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:257d011919f133a4746958257f2c75238e3ff54255acd5e3e11f3ff41fd14256", size = 222571, upload-time = "2025-07-01T15:54:54.822Z" },
{ url = "https://files.pythonhosted.org/packages/89/bf/3d970ba2e2bcd17d2912cb42874107390f72873e38e79267224110de5e61/rpds_py-0.26.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:529c8156d7506fba5740e05da8795688f87119cce330c244519cf706a4a3d618", size = 360475, upload-time = "2025-07-01T15:54:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/82/9f/283e7e2979fc4ec2d8ecee506d5a3675fce5ed9b4b7cb387ea5d37c2f18d/rpds_py-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f53ec51f9d24e9638a40cabb95078ade8c99251945dad8d57bf4aabe86ecee35", size = 346692, upload-time = "2025-07-01T15:54:58.561Z" },
{ url = "https://files.pythonhosted.org/packages/e3/03/7e50423c04d78daf391da3cc4330bdb97042fc192a58b186f2d5deb7befd/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab504c4d654e4a29558eaa5bb8cea5fdc1703ea60a8099ffd9c758472cf913f", size = 379415, upload-time = "2025-07-01T15:54:59.751Z" },
{ url = "https://files.pythonhosted.org/packages/57/00/d11ee60d4d3b16808432417951c63df803afb0e0fc672b5e8d07e9edaaae/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd0641abca296bc1a00183fe44f7fced8807ed49d501f188faa642d0e4975b83", size = 391783, upload-time = "2025-07-01T15:55:00.898Z" },
{ url = "https://files.pythonhosted.org/packages/08/b3/1069c394d9c0d6d23c5b522e1f6546b65793a22950f6e0210adcc6f97c3e/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b312fecc1d017b5327afa81d4da1480f51c68810963a7336d92203dbb3d4f1", size = 512844, upload-time = "2025-07-01T15:55:02.201Z" },
{ url = "https://files.pythonhosted.org/packages/08/3b/c4fbf0926800ed70b2c245ceca99c49f066456755f5d6eb8863c2c51e6d0/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c741107203954f6fc34d3066d213d0a0c40f7bb5aafd698fb39888af277c70d8", size = 402105, upload-time = "2025-07-01T15:55:03.698Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b0/db69b52ca07413e568dae9dc674627a22297abb144c4d6022c6d78f1e5cc/rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3e55a7db08dc9a6ed5fb7103019d2c1a38a349ac41901f9f66d7f95750942f", size = 383440, upload-time = "2025-07-01T15:55:05.398Z" },
{ url = "https://files.pythonhosted.org/packages/4c/e1/c65255ad5b63903e56b3bb3ff9dcc3f4f5c3badde5d08c741ee03903e951/rpds_py-0.26.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e851920caab2dbcae311fd28f4313c6953993893eb5c1bb367ec69d9a39e7ed", size = 412759, upload-time = "2025-07-01T15:55:08.316Z" },
{ url = "https://files.pythonhosted.org/packages/e4/22/bb731077872377a93c6e93b8a9487d0406c70208985831034ccdeed39c8e/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dfbf280da5f876d0b00c81f26bedce274e72a678c28845453885a9b3c22ae632", size = 556032, upload-time = "2025-07-01T15:55:09.52Z" },
{ url = "https://files.pythonhosted.org/packages/e0/8b/393322ce7bac5c4530fb96fc79cc9ea2f83e968ff5f6e873f905c493e1c4/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1cc81d14ddfa53d7f3906694d35d54d9d3f850ef8e4e99ee68bc0d1e5fed9a9c", size = 585416, upload-time = "2025-07-01T15:55:11.216Z" },
{ url = "https://files.pythonhosted.org/packages/49/ae/769dc372211835bf759319a7aae70525c6eb523e3371842c65b7ef41c9c6/rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dca83c498b4650a91efcf7b88d669b170256bf8017a5db6f3e06c2bf031f57e0", size = 554049, upload-time = "2025-07-01T15:55:13.004Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f9/4c43f9cc203d6ba44ce3146246cdc38619d92c7bd7bad4946a3491bd5b70/rpds_py-0.26.0-cp313-cp313t-win32.whl", hash = "sha256:4d11382bcaf12f80b51d790dee295c56a159633a8e81e6323b16e55d81ae37e9", size = 218428, upload-time = "2025-07-01T15:55:14.486Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8b/9286b7e822036a4a977f2f1e851c7345c20528dbd56b687bb67ed68a8ede/rpds_py-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff110acded3c22c033e637dd8896e411c7d3a11289b2edf041f86663dbc791e9", size = 231524, upload-time = "2025-07-01T15:55:15.745Z" },
{ url = "https://files.pythonhosted.org/packages/55/07/029b7c45db910c74e182de626dfdae0ad489a949d84a468465cd0ca36355/rpds_py-0.26.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:da619979df60a940cd434084355c514c25cf8eb4cf9a508510682f6c851a4f7a", size = 364292, upload-time = "2025-07-01T15:55:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/13/d1/9b3d3f986216b4d1f584878dca15ce4797aaf5d372d738974ba737bf68d6/rpds_py-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea89a2458a1a75f87caabefe789c87539ea4e43b40f18cff526052e35bbb4fdf", size = 350334, upload-time = "2025-07-01T15:55:18.922Z" },
{ url = "https://files.pythonhosted.org/packages/18/98/16d5e7bc9ec715fa9668731d0cf97f6b032724e61696e2db3d47aeb89214/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feac1045b3327a45944e7dcbeb57530339f6b17baff154df51ef8b0da34c8c12", size = 384875, upload-time = "2025-07-01T15:55:20.399Z" },
{ url = "https://files.pythonhosted.org/packages/f9/13/aa5e2b1ec5ab0e86a5c464d53514c0467bec6ba2507027d35fc81818358e/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b818a592bd69bfe437ee8368603d4a2d928c34cffcdf77c2e761a759ffd17d20", size = 399993, upload-time = "2025-07-01T15:55:21.729Z" },
{ url = "https://files.pythonhosted.org/packages/17/03/8021810b0e97923abdbab6474c8b77c69bcb4b2c58330777df9ff69dc559/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a8b0dd8648709b62d9372fc00a57466f5fdeefed666afe3fea5a6c9539a0331", size = 516683, upload-time = "2025-07-01T15:55:22.918Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b1/da8e61c87c2f3d836954239fdbbfb477bb7b54d74974d8f6fcb34342d166/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d3498ad0df07d81112aa6ec6c95a7e7b1ae00929fb73e7ebee0f3faaeabad2f", size = 408825, upload-time = "2025-07-01T15:55:24.207Z" },
{ url = "https://files.pythonhosted.org/packages/38/bc/1fc173edaaa0e52c94b02a655db20697cb5fa954ad5a8e15a2c784c5cbdd/rpds_py-0.26.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4146ccb15be237fdef10f331c568e1b0e505f8c8c9ed5d67759dac58ac246", size = 387292, upload-time = "2025-07-01T15:55:25.554Z" },
{ url = "https://files.pythonhosted.org/packages/7c/eb/3a9bb4bd90867d21916f253caf4f0d0be7098671b6715ad1cead9fe7bab9/rpds_py-0.26.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9a63785467b2d73635957d32a4f6e73d5e4df497a16a6392fa066b753e87387", size = 420435, upload-time = "2025-07-01T15:55:27.798Z" },
{ url = "https://files.pythonhosted.org/packages/cd/16/e066dcdb56f5632713445271a3f8d3d0b426d51ae9c0cca387799df58b02/rpds_py-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de4ed93a8c91debfd5a047be327b7cc8b0cc6afe32a716bbbc4aedca9e2a83af", size = 562410, upload-time = "2025-07-01T15:55:29.057Z" },
{ url = "https://files.pythonhosted.org/packages/60/22/ddbdec7eb82a0dc2e455be44c97c71c232983e21349836ce9f272e8a3c29/rpds_py-0.26.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:caf51943715b12af827696ec395bfa68f090a4c1a1d2509eb4e2cb69abbbdb33", size = 590724, upload-time = "2025-07-01T15:55:30.719Z" },
{ url = "https://files.pythonhosted.org/packages/2c/b4/95744085e65b7187d83f2fcb0bef70716a1ea0a9e5d8f7f39a86e5d83424/rpds_py-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a59e5bc386de021f56337f757301b337d7ab58baa40174fb150accd480bc953", size = 558285, upload-time = "2025-07-01T15:55:31.981Z" },
{ url = "https://files.pythonhosted.org/packages/37/37/6309a75e464d1da2559446f9c811aa4d16343cebe3dbb73701e63f760caa/rpds_py-0.26.0-cp314-cp314-win32.whl", hash = "sha256:92c8db839367ef16a662478f0a2fe13e15f2227da3c1430a782ad0f6ee009ec9", size = 223459, upload-time = "2025-07-01T15:55:33.312Z" },
{ url = "https://files.pythonhosted.org/packages/d9/6f/8e9c11214c46098b1d1391b7e02b70bb689ab963db3b19540cba17315291/rpds_py-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:b0afb8cdd034150d4d9f53926226ed27ad15b7f465e93d7468caaf5eafae0d37", size = 236083, upload-time = "2025-07-01T15:55:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/47/af/9c4638994dd623d51c39892edd9d08e8be8220a4b7e874fa02c2d6e91955/rpds_py-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:ca3f059f4ba485d90c8dc75cb5ca897e15325e4e609812ce57f896607c1c0867", size = 223291, upload-time = "2025-07-01T15:55:36.202Z" },
{ url = "https://files.pythonhosted.org/packages/4d/db/669a241144460474aab03e254326b32c42def83eb23458a10d163cb9b5ce/rpds_py-0.26.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5afea17ab3a126006dc2f293b14ffc7ef3c85336cf451564a0515ed7648033da", size = 361445, upload-time = "2025-07-01T15:55:37.483Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/133f61cc5807c6c2fd086a46df0eb8f63a23f5df8306ff9f6d0fd168fecc/rpds_py-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69f0c0a3df7fd3a7eec50a00396104bb9a843ea6d45fcc31c2d5243446ffd7a7", size = 347206, upload-time = "2025-07-01T15:55:38.828Z" },
{ url = "https://files.pythonhosted.org/packages/05/bf/0e8fb4c05f70273469eecf82f6ccf37248558526a45321644826555db31b/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:801a71f70f9813e82d2513c9a96532551fce1e278ec0c64610992c49c04c2dad", size = 380330, upload-time = "2025-07-01T15:55:40.175Z" },
{ url = "https://files.pythonhosted.org/packages/d4/a8/060d24185d8b24d3923322f8d0ede16df4ade226a74e747b8c7c978e3dd3/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df52098cde6d5e02fa75c1f6244f07971773adb4a26625edd5c18fee906fa84d", size = 392254, upload-time = "2025-07-01T15:55:42.015Z" },
{ url = "https://files.pythonhosted.org/packages/b9/7b/7c2e8a9ee3e6bc0bae26bf29f5219955ca2fbb761dca996a83f5d2f773fe/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bc596b30f86dc6f0929499c9e574601679d0341a0108c25b9b358a042f51bca", size = 516094, upload-time = "2025-07-01T15:55:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/75/d6/f61cafbed8ba1499b9af9f1777a2a199cd888f74a96133d8833ce5eaa9c5/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9dfbe56b299cf5875b68eb6f0ebaadc9cac520a1989cac0db0765abfb3709c19", size = 402889, upload-time = "2025-07-01T15:55:45.275Z" },
{ url = "https://files.pythonhosted.org/packages/92/19/c8ac0a8a8df2dd30cdec27f69298a5c13e9029500d6d76718130f5e5be10/rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac64f4b2bdb4ea622175c9ab7cf09444e412e22c0e02e906978b3b488af5fde8", size = 384301, upload-time = "2025-07-01T15:55:47.098Z" },
{ url = "https://files.pythonhosted.org/packages/41/e1/6b1859898bc292a9ce5776016c7312b672da00e25cec74d7beced1027286/rpds_py-0.26.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:181ef9b6bbf9845a264f9aa45c31836e9f3c1f13be565d0d010e964c661d1e2b", size = 412891, upload-time = "2025-07-01T15:55:48.412Z" },
{ url = "https://files.pythonhosted.org/packages/ef/b9/ceb39af29913c07966a61367b3c08b4f71fad841e32c6b59a129d5974698/rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:49028aa684c144ea502a8e847d23aed5e4c2ef7cadfa7d5eaafcb40864844b7a", size = 557044, upload-time = "2025-07-01T15:55:49.816Z" },
{ url = "https://files.pythonhosted.org/packages/2f/27/35637b98380731a521f8ec4f3fd94e477964f04f6b2f8f7af8a2d889a4af/rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e5d524d68a474a9688336045bbf76cb0def88549c1b2ad9dbfec1fb7cfbe9170", size = 585774, upload-time = "2025-07-01T15:55:51.192Z" },
{ url = "https://files.pythonhosted.org/packages/52/d9/3f0f105420fecd18551b678c9a6ce60bd23986098b252a56d35781b3e7e9/rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1851f429b822831bd2edcbe0cfd12ee9ea77868f8d3daf267b189371671c80e", size = 554886, upload-time = "2025-07-01T15:55:52.541Z" },
{ url = "https://files.pythonhosted.org/packages/6b/c5/347c056a90dc8dd9bc240a08c527315008e1b5042e7a4cf4ac027be9d38a/rpds_py-0.26.0-cp314-cp314t-win32.whl", hash = "sha256:7bdb17009696214c3b66bb3590c6d62e14ac5935e53e929bcdbc5a495987a84f", size = 219027, upload-time = "2025-07-01T15:55:53.874Z" },
{ url = "https://files.pythonhosted.org/packages/75/04/5302cea1aa26d886d34cadbf2dc77d90d7737e576c0065f357b96dc7a1a6/rpds_py-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f14440b9573a6f76b4ee4770c13f0b5921f71dde3b6fcb8dabbefd13b7fe05d7", size = 232821, upload-time = "2025-07-01T15:55:55.167Z" },
{ url = "https://files.pythonhosted.org/packages/ef/9a/1f033b0b31253d03d785b0cd905bc127e555ab496ea6b4c7c2e1f951f2fd/rpds_py-0.26.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3c0909c5234543ada2515c05dc08595b08d621ba919629e94427e8e03539c958", size = 373226, upload-time = "2025-07-01T15:56:16.578Z" },
{ url = "https://files.pythonhosted.org/packages/58/29/5f88023fd6aaaa8ca3c4a6357ebb23f6f07da6079093ccf27c99efce87db/rpds_py-0.26.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c1fb0cda2abcc0ac62f64e2ea4b4e64c57dfd6b885e693095460c61bde7bb18e", size = 359230, upload-time = "2025-07-01T15:56:17.978Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6c/13eaebd28b439da6964dde22712b52e53fe2824af0223b8e403249d10405/rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d142d2d6cf9b31c12aa4878d82ed3b2324226270b89b676ac62ccd7df52d08", size = 382363, upload-time = "2025-07-01T15:56:19.977Z" },
{ url = "https://files.pythonhosted.org/packages/55/fc/3bb9c486b06da19448646f96147796de23c5811ef77cbfc26f17307b6a9d/rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a547e21c5610b7e9093d870be50682a6a6cf180d6da0f42c47c306073bfdbbf6", size = 397146, upload-time = "2025-07-01T15:56:21.39Z" },
{ url = "https://files.pythonhosted.org/packages/15/18/9d1b79eb4d18e64ba8bba9e7dec6f9d6920b639f22f07ee9368ca35d4673/rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35e9a70a0f335371275cdcd08bc5b8051ac494dd58bff3bbfb421038220dc871", size = 514804, upload-time = "2025-07-01T15:56:22.78Z" },
{ url = "https://files.pythonhosted.org/packages/4f/5a/175ad7191bdbcd28785204621b225ad70e85cdfd1e09cc414cb554633b21/rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dfa6115c6def37905344d56fb54c03afc49104e2ca473d5dedec0f6606913b4", size = 402820, upload-time = "2025-07-01T15:56:24.584Z" },
{ url = "https://files.pythonhosted.org/packages/11/45/6a67ecf6d61c4d4aff4bc056e864eec4b2447787e11d1c2c9a0242c6e92a/rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:313cfcd6af1a55a286a3c9a25f64af6d0e46cf60bc5798f1db152d97a216ff6f", size = 384567, upload-time = "2025-07-01T15:56:26.064Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ba/16589da828732b46454c61858950a78fe4c931ea4bf95f17432ffe64b241/rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7bf2496fa563c046d05e4d232d7b7fd61346e2402052064b773e5c378bf6f73", size = 416520, upload-time = "2025-07-01T15:56:27.608Z" },
{ url = "https://files.pythonhosted.org/packages/81/4b/00092999fc7c0c266045e984d56b7314734cc400a6c6dc4d61a35f135a9d/rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aa81873e2c8c5aa616ab8e017a481a96742fdf9313c40f14338ca7dbf50cb55f", size = 559362, upload-time = "2025-07-01T15:56:29.078Z" },
{ url = "https://files.pythonhosted.org/packages/96/0c/43737053cde1f93ac4945157f7be1428724ab943e2132a0d235a7e161d4e/rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:68ffcf982715f5b5b7686bdd349ff75d422e8f22551000c24b30eaa1b7f7ae84", size = 588113, upload-time = "2025-07-01T15:56:30.485Z" },
{ url = "https://files.pythonhosted.org/packages/46/46/8e38f6161466e60a997ed7e9951ae5de131dedc3cf778ad35994b4af823d/rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6188de70e190847bb6db3dc3981cbadff87d27d6fe9b4f0e18726d55795cee9b", size = 555429, upload-time = "2025-07-01T15:56:31.956Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ac/65da605e9f1dd643ebe615d5bbd11b6efa1d69644fc4bf623ea5ae385a82/rpds_py-0.26.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1c962145c7473723df9722ba4c058de12eb5ebedcb4e27e7d902920aa3831ee8", size = 231950, upload-time = "2025-07-01T15:56:33.337Z" },
{ url = "https://files.pythonhosted.org/packages/51/f2/b5c85b758a00c513bb0389f8fc8e61eb5423050c91c958cdd21843faa3e6/rpds_py-0.26.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f61a9326f80ca59214d1cceb0a09bb2ece5b2563d4e0cd37bfd5515c28510674", size = 373505, upload-time = "2025-07-01T15:56:34.716Z" },
{ url = "https://files.pythonhosted.org/packages/23/e0/25db45e391251118e915e541995bb5f5ac5691a3b98fb233020ba53afc9b/rpds_py-0.26.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:183f857a53bcf4b1b42ef0f57ca553ab56bdd170e49d8091e96c51c3d69ca696", size = 359468, upload-time = "2025-07-01T15:56:36.219Z" },
{ url = "https://files.pythonhosted.org/packages/0b/73/dd5ee6075bb6491be3a646b301dfd814f9486d924137a5098e61f0487e16/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:941c1cfdf4799d623cf3aa1d326a6b4fdb7a5799ee2687f3516738216d2262fb", size = 382680, upload-time = "2025-07-01T15:56:37.644Z" },
{ url = "https://files.pythonhosted.org/packages/2f/10/84b522ff58763a5c443f5bcedc1820240e454ce4e620e88520f04589e2ea/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72a8d9564a717ee291f554eeb4bfeafe2309d5ec0aa6c475170bdab0f9ee8e88", size = 397035, upload-time = "2025-07-01T15:56:39.241Z" },
{ url = "https://files.pythonhosted.org/packages/06/ea/8667604229a10a520fcbf78b30ccc278977dcc0627beb7ea2c96b3becef0/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511d15193cbe013619dd05414c35a7dedf2088fcee93c6bbb7c77859765bd4e8", size = 514922, upload-time = "2025-07-01T15:56:40.645Z" },
{ url = "https://files.pythonhosted.org/packages/24/e6/9ed5b625c0661c4882fc8cdf302bf8e96c73c40de99c31e0b95ed37d508c/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1f9741b603a8d8fedb0ed5502c2bc0accbc51f43e2ad1337fe7259c2b77a5", size = 402822, upload-time = "2025-07-01T15:56:42.137Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/212c7b6fd51946047fb45d3733da27e2fa8f7384a13457c874186af691b1/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4019a9d473c708cf2f16415688ef0b4639e07abaa569d72f74745bbeffafa2c7", size = 384336, upload-time = "2025-07-01T15:56:44.239Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f5/a40ba78748ae8ebf4934d4b88e77b98497378bc2c24ba55ebe87a4e87057/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:093d63b4b0f52d98ebae33b8c50900d3d67e0666094b1be7a12fffd7f65de74b", size = 416871, upload-time = "2025-07-01T15:56:46.284Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a6/33b1fc0c9f7dcfcfc4a4353daa6308b3ece22496ceece348b3e7a7559a09/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2abe21d8ba64cded53a2a677e149ceb76dcf44284202d737178afe7ba540c1eb", size = 559439, upload-time = "2025-07-01T15:56:48.549Z" },
{ url = "https://files.pythonhosted.org/packages/71/2d/ceb3f9c12f8cfa56d34995097f6cd99da1325642c60d1b6680dd9df03ed8/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:4feb7511c29f8442cbbc28149a92093d32e815a28aa2c50d333826ad2a20fdf0", size = 588380, upload-time = "2025-07-01T15:56:50.086Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ed/9de62c2150ca8e2e5858acf3f4f4d0d180a38feef9fdab4078bea63d8dba/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c", size = 555334, upload-time = "2025-07-01T15:56:51.703Z" },
]
[[package]]
name = "ruff"
version = "0.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload-time = "2025-07-03T16:40:19.566Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload-time = "2025-07-03T16:39:38.847Z" },
{ url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload-time = "2025-07-03T16:39:42.294Z" },
{ url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload-time = "2025-07-03T16:39:44.75Z" },
{ url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload-time = "2025-07-03T16:39:47.652Z" },
{ url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload-time = "2025-07-03T16:39:49.641Z" },
{ url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload-time = "2025-07-03T16:39:52.069Z" },
{ url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload-time = "2025-07-03T16:39:54.551Z" },
{ url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload-time = "2025-07-03T16:39:57.55Z" },
{ url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload-time = "2025-07-03T16:39:59.78Z" },
{ url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload-time = "2025-07-03T16:40:01.934Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload-time = "2025-07-03T16:40:04.363Z" },
{ url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload-time = "2025-07-03T16:40:06.514Z" },
{ url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload-time = "2025-07-03T16:40:08.708Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload-time = "2025-07-03T16:40:10.836Z" },
{ url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823, upload-time = "2025-07-03T16:40:13.203Z" },
{ url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831, upload-time = "2025-07-03T16:40:15.478Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334, upload-time = "2025-07-03T16:40:17.677Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sse-starlette"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/3e/eae74d8d33e3262bae0a7e023bb43d8bdd27980aa3557333f4632611151f/sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926", size = 18635, upload-time = "2025-07-06T09:41:33.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/f1/6c7eaa8187ba789a6dd6d74430307478d2a91c23a5452ab339b6fbe15a08/sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a", size = 10824, upload-time = "2025-07-06T09:41:32.321Z" },
]
[[package]]
name = "starlette"
version = "0.47.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/69/662169fdb92fb96ec3eaee218cf540a629d629c86d7993d9651226a6789b/starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b", size = 2583072, upload-time = "2025-06-21T04:03:17.337Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" },
]
[[package]]
name = "tomli"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
name = "uvicorn"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
# Semantic Kernel Processes in Dapr
This demo contains a FastAPI app that uses Dapr to run a Semantic Kernel Process. Dapr is a portable, event-driven runtime that can simplify the process of building resilient, stateful application that run in the cloud and/or edge. Dapr is a natural fit for hosting Semantic Kernel Processes and allows you to scale your processes in size and quantity without sacrificing performance, or reliability.
For more information about Semantic Kernel Processes and Dapr, see the following documentation:
#### Semantic Kernel Processes
- [Overview of the Process Framework (docs)](https://learn.microsoft.com/semantic-kernel/frameworks/process/process-framework)
- [Getting Started with Processes (samples)](../../getting_started_with_processes/)
- [Semantic Kernel Dapr Runtime](../../../semantic_kernel/processes/dapr_runtime/)
#### Dapr
- [Dapr documentation](https://docs.dapr.io/)
- [Dapr Actor documentation](https://v1-10.docs.dapr.io/developing-applications/building-blocks/actors/)
- [Dapr local development](https://docs.dapr.io/getting-started/install-dapr-selfhost/)
### Supported Dapr Extensions:
| Extension | Supported |
|--------------------|:----:|
| FastAPI | ✅ |
| Flask | ✅ |
| gRPC | ❌ |
| Dapr Workflow | ❌ |
## Running the Demo
Before running this Demo, make sure to configure Dapr for local development following the links above. The Dapr containers must be running for this demo application to run.
```mermaid
flowchart LR
Kickoff --> A
Kickoff --> B
A --> C
B --> C
C -->|Count < 3| Kickoff
C -->|Count >= 3| End
classDef kickoffClass fill:#f9f,stroke:#333,stroke-width:2px;
class Kickoff kickoffClass;
End((End))
```
1. Build and run the sample. Running the Dapr service locally can be done using the Dapr Cli or with the Dapr VS Code extension. The VS Code extension is the recommended approach if you want to debug the code as it runs.
- If using VSCode to debug, select either the `Python FastAPI App with Dapr` or the `Python Flask API App with Dapr` option from the Run and Debug dropdown list.
1. When the service is up and running, it will expose a single API in localhost port 5001.
#### Invoking the process:
1. Open a web browser and point it to [http://localhost:5001/processes/1234](http://localhost:5001/processes/1234) to invoke a new process with `Id = "1234"`
1. When the process is complete, you should see `{"processId":"1234"}` in the web browser.
1. You should also see console output from the running service with logs that match the following:
```text
##### Kickoff ran.
##### AStep ran.
##### BStep ran.
##### CStep activated with Cycle = '1'.
##### CStep run cycle 2.
##### Kickoff ran.
##### AStep ran.
##### BStep ran.
##### CStep run cycle 3 - exiting.
```
Now refresh the page in your browser to run the same processes instance again. Now the logs should look like this:
```text
##### Kickoff ran.
##### AStep ran.
##### BStep ran.
##### CStep run cycle 4 - exiting.
```
Notice that the logs from the two runs are not the same. In the first run, the processes has not been run before and so it's initial
state came from what we defined in the process:
**_First Run_**
- `CState` is initialized with `Cycle = 1` which is the initial state that we specified while building the process.
- `CState` is invoked a total of two times before the terminal condition of `Cycle >= 3` is reached.
In the second run however, the process has persisted state from the first run:
**_Second Run_**
- `CState` is initialized with `Cycle = 3` which is the final state from the first run of the process.
- `CState` is invoked only once and is already in the terminal condition of `Cycle >= 3`.
If you create a new instance of the process with `Id = "ABCD"` by pointing your browser to [http://localhost:5001/processes/ABCD](http://localhost:5001/processes/ABCD), you will see the it will start with the initial state as expected.
## Understanding the Code
Below are the key aspects of the code that show how Dapr and Semantic Kernel Processes can be integrated into a FastAPI app:
- Create a new Dapr FastAPI app.
- Add the required Semantic Kernel and Dapr packages to your project:
**_General Imports and Dapr Packages_**
**_FastAPI App_**
```python
import logging
from contextlib import asynccontextmanager
import uvicorn
from dapr.ext.fastapi import DaprActor
from fastapi import FastAPI
from fastapi.responses import JSONResponse
```
**_Flask API App_**
```python
import asyncio
import logging
from flask import Flask, jsonify
from flask_dapr.actor import DaprActor
```
**_Semantic Kernel Process Imports_**
```python
from samples.demos.process_with_dapr.process.process import get_process
from samples.demos.process_with_dapr.process.steps import CommonEvents
from semantic_kernel import Kernel
from semantic_kernel.processes.dapr_runtime import (
register_fastapi_dapr_actors,
start,
)
```
**_Define the FastAPI app, Dapr App, and the DaprActor_**
```python
# Define the kernel that is used throughout the process
kernel = Kernel()
# Define a lifespan method that registers the actors with the Dapr runtime
@asynccontextmanager
async def lifespan(app: FastAPI):
print("## actor startup ##")
await register_fastapi_dapr_actors(actor, kernel)
yield
# Define the FastAPI app along with the DaprActor
app = FastAPI(title="SKProcess", lifespan=lifespan)
actor = DaprActor(app)
```
If using Flask, you will define:
```python
kernel = Kernel()
app = Flask("SKProcess")
# Enable DaprActor Flask extension
actor = DaprActor(app)
# Synchronously register actors
print("## actor startup ##")
register_flask_dapr_actors(actor, kernel)
# Create the global event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
```
- Build and run a Process as you normally would. For this Demo we run a simple example process from with either a FastAPI or a Flask API method in response to a GET request.
- [See the FastAPI app here](./fastapi_app.py).
- [See the Flask API app here](./flask_app.py)
@@ -0,0 +1,85 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from contextlib import asynccontextmanager
import uvicorn
from dapr.ext.fastapi import DaprActor
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from samples.demos.process_with_dapr.process.process import get_process
from samples.demos.process_with_dapr.process.steps import CommonEvents, CStepState
from semantic_kernel import Kernel
from semantic_kernel.processes.dapr_runtime import register_fastapi_dapr_actors, start
from semantic_kernel.processes.dapr_runtime.dapr_kernel_process_context import DaprKernelProcessContext
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
logging.basicConfig(level=logging.WARNING)
# Define the kernel that is used throughout the process
kernel = Kernel()
"""
The following Process and Dapr runtime sample uses a FastAPI app
to start a process and run steps. The process is defined in the
process/process.py file and the steps are defined in the steps.py
file. The process is started by calling the /processes/{process_id}
endpoint. The actors are registered with the Dapr runtime using
the DaprActor class. The ProcessActor and the StepActor require a
kernel dependency to be injected during creation. This is done by
defining a factory function that takes the kernel as a parameter
and returns the actor instance with the kernel injected.
"""
# Get the process which means we have the `KernelProcess` object
# along with any defined step factories
process = get_process()
# Define a lifespan method that registers the actors with the Dapr runtime
@asynccontextmanager
async def lifespan(app: FastAPI):
print("## actor startup ##")
await register_fastapi_dapr_actors(actor, kernel, process.factories)
yield
# Define the FastAPI app along with the DaprActor
app = FastAPI(title="SKProcess", lifespan=lifespan)
actor = DaprActor(app)
@app.get("/healthz")
async def healthcheck():
return "Healthy!"
@app.get("/processes/{process_id}")
async def start_process(process_id: str):
try:
context: DaprKernelProcessContext = await start(
process=process,
kernel=kernel,
initial_event=CommonEvents.StartProcess,
process_id=process_id,
)
kernel_process = await context.get_state()
# If desired, uncomment the following lines to see the process state
# final_state = kernel_process.to_process_state_metadata()
# print(final_state.model_dump(exclude_none=True, by_alias=True, mode="json"))
c_step_state: KernelProcessStepState[CStepState] = next(
(s.state for s in kernel_process.steps if s.state.name == "CStep"), None
)
c_step_state_validated = CStepState.model_validate(c_step_state.state)
print(f"[FINAL STEP STATE]: CStepState current cycle: {c_step_state_validated.current_cycle}")
return JSONResponse(content={"processId": process_id}, status_code=200)
except Exception:
return JSONResponse(content={"error": "Error starting process"}, status_code=500)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=5001, log_level="error") # nosec
@@ -0,0 +1,64 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from flask import Flask, jsonify
from flask_dapr.actor import DaprActor
from samples.demos.process_with_dapr.process.process import get_process
from samples.demos.process_with_dapr.process.steps import CommonEvents
from semantic_kernel import Kernel
from semantic_kernel.processes.dapr_runtime import (
register_flask_dapr_actors,
start,
)
logging.basicConfig(level=logging.ERROR)
# Define the kernel that is used throughout the process
kernel = Kernel()
app = Flask("SKProcess")
# Enable DaprActor Flask extension
actor = DaprActor(app)
# Synchronously register actors
print("## actor startup ##")
register_flask_dapr_actors(actor, kernel)
# Create the global event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
@app.route("/healthz", methods=["GET"])
def healthcheck():
return "Healthy!", 200
@app.route("/processes/<process_id>", methods=["GET"])
def start_process(process_id):
try:
process = get_process()
# Run the start coroutine in a synchronous manner
asyncio.set_event_loop(loop)
_ = asyncio.run(
start(
process=process,
kernel=kernel,
initial_event=CommonEvents.StartProcess,
process_id=process_id,
)
)
return jsonify({"processId": process_id}), 200
except Exception:
return jsonify({"error": "Error starting process"}), 500
# Run application
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001) # nosec
@@ -0,0 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from samples.demos.process_with_dapr.process.process import get_process
from samples.demos.process_with_dapr.process.steps import AStep, BStep, CStep, KickOffStep
__all__ = ["AStep", "BStep", "KickOffStep", "CStep", "get_process"]
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING
from samples.demos.process_with_dapr.process.steps import (
AStep,
BStep,
CommonEvents,
CStep,
CStepState,
KickOffStep,
bstep_factory,
)
from semantic_kernel.processes import ProcessBuilder
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
def get_process() -> "KernelProcess":
# Define the process builder
process = ProcessBuilder(name="ProcessWithDapr")
# Add the step types to the builder
kickoff_step = process.add_step(step_type=KickOffStep)
myAStep = process.add_step(step_type=AStep)
myBStep = process.add_step(step_type=BStep, factory_function=bstep_factory)
# Initialize the CStep with an initial state and the state's current cycle set to 1
myCStep = process.add_step(step_type=CStep, initial_state=CStepState(current_cycle=1))
# Define the input event and where to send it to
process.on_input_event(event_id=CommonEvents.StartProcess).send_event_to(target=kickoff_step)
# Define the process flow
kickoff_step.on_event(event_id=CommonEvents.StartARequested).send_event_to(target=myAStep)
kickoff_step.on_event(event_id=CommonEvents.StartBRequested).send_event_to(target=myBStep)
myAStep.on_event(event_id=CommonEvents.AStepDone).send_event_to(target=myCStep, parameter_name="astepdata")
# Define the fan in behavior once both AStep and BStep are done
myBStep.on_event(event_id=CommonEvents.BStepDone).send_event_to(target=myCStep, parameter_name="bstepdata")
myCStep.on_event(event_id=CommonEvents.CStepDone).send_event_to(target=kickoff_step)
myCStep.on_event(event_id=CommonEvents.ExitRequested).stop_process()
# Build the process
return process.build()
@@ -0,0 +1,119 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from enum import Enum
from typing import ClassVar
from azure.identity import AzureCliCredential
from pydantic import Field
from semantic_kernel.agents import ChatCompletionAgent, ChatHistoryAgentThread
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from semantic_kernel.functions import kernel_function
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process import (
KernelProcessStep,
KernelProcessStepContext,
KernelProcessStepState,
)
class CommonEvents(Enum):
"""Common events for the sample process."""
UserInputReceived = "UserInputReceived"
CompletionResponseGenerated = "CompletionResponseGenerated"
WelcomeDone = "WelcomeDone"
AStepDone = "AStepDone"
BStepDone = "BStepDone"
CStepDone = "CStepDone"
StartARequested = "StartARequested"
StartBRequested = "StartBRequested"
ExitRequested = "ExitRequested"
StartProcess = "StartProcess"
# Define a sample step that once the `on_input_event` is received,
# it will emit two events to start the A and B steps.
class KickOffStep(KernelProcessStep):
KICK_OFF_FUNCTION: ClassVar[str] = "kick_off"
@kernel_function(name=KICK_OFF_FUNCTION)
async def print_welcome_message(self, context: KernelProcessStepContext):
print("##### Kickoff ran.")
await context.emit_event(process_event=CommonEvents.StartARequested, data="Get Going A")
await context.emit_event(process_event=CommonEvents.StartBRequested, data="Get Going B")
# Define a sample `AStep` step that will emit an event after 1 second.
# The event will be sent to the `CStep` step with the data `I did A`.
class AStep(KernelProcessStep):
@kernel_function()
async def do_it(self, context: KernelProcessStepContext):
print("##### AStep ran.")
await asyncio.sleep(1)
await context.emit_event(process_event=CommonEvents.AStepDone, data="I did A")
# Define a simple factory for the BStep that can create the dependency that the BStep requires
# As an example, this factory creates a kernel and adds an `AzureChatCompletion` service to it.
async def bstep_factory():
"""Creates a BStep instance with ephemeral references like ChatCompletionAgent."""
agent = ChatCompletionAgent(
service=AzureChatCompletion(credential=AzureCliCredential()), name="echo", instructions="repeat the input back"
)
step_instance = BStep()
step_instance.agent = agent
step_instance.thread = ChatHistoryAgentThread()
return step_instance
class BStep(KernelProcessStep):
"""A sample BStep that optionally holds a ChatCompletionAgent.
By design, the agent is ephemeral (not stored in state).
"""
# Ephemeral references won't be persisted to Dapr
# because we do not place them in a step state model.
# We'll set this in the factory function:
agent: ChatCompletionAgent | None = None
thread: ChatHistoryAgentThread | None = None
@kernel_function(name="do_it")
async def do_it(self, context: KernelProcessStepContext):
print("##### BStep ran (do_it).")
await asyncio.sleep(2)
if self.agent:
response = await self.agent.get_response(messages="Hello from BStep!")
print(f"BStep got agent response: {response.content}")
await context.emit_event(process_event="BStepDone", data="I did B")
# Define a sample `CStepState` that will keep track of the current cycle.
class CStepState(KernelBaseModel):
current_cycle: int = 1
# Define a sample `CStep` step that will emit an `ExitRequested` event after 3 cycles.
class CStep(KernelProcessStep[CStepState]):
state: CStepState = Field(default_factory=CStepState)
# The activate method overrides the base class method to set the state in the step.
async def activate(self, state: KernelProcessStepState[CStepState]):
"""Activates the step and sets the state."""
self.state = state.state
print(f"##### CStep activated with Cycle = '{self.state.current_cycle}'.")
@kernel_function()
async def do_it(self, context: KernelProcessStepContext, astepdata: str, bstepdata: str):
self.state.current_cycle += 1
if self.state.current_cycle >= 3:
print("##### CStep run cycle 3 - exiting.")
await context.emit_event(process_event=CommonEvents.ExitRequested)
return
print(f"##### CStep run cycle {self.state.current_cycle}")
await context.emit_event(process_event=CommonEvents.CStepDone)
@@ -0,0 +1,4 @@
TELEMETRY_SAMPLE_CONNECTION_STRING="..."
OTLP_ENDPOINT="http://localhost:4317/"
SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS=true
SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE=true
+212
View File
@@ -0,0 +1,212 @@
# Semantic Kernel Python Telemetry
This sample project shows how a Python application can be configured to send Semantic Kernel telemetry to the Application Performance Management (APM) vendors of your choice.
In this sample, we provide options to send telemetry to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash), and console output.
> Note that it is also possible to use other Application Performance Management (APM) vendors. An example is [Prometheus](https://prometheus.io/docs/introduction/overview/). Please refer to this [link](https://opentelemetry.io/docs/languages/python/exporters/) to learn more about exporters.
For more information, please refer to the following resources:
1. [Azure Monitor OpenTelemetry Exporter](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/monitor/azure-monitor-opentelemetry-exporter)
2. [Aspire Dashboard for Python Apps](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone-for-python?tabs=flask%2Cwindows)
3. [Python Logging](https://docs.python.org/3/library/logging.html)
4. [Observability in Python](https://www.cncf.io/blog/2022/04/22/opentelemetry-and-python-a-complete-instrumentation-guide/)
## What to expect
The Semantic Kernel Python SDK is designed to efficiently generate comprehensive logs, traces, and metrics throughout the flow of function execution and model invocation. This allows you to effectively monitor your AI application's performance and accurately track token consumption.
## Configuration
### Required resources
2. OpenAI or [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal)
### Optional resources
1. [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/create-workspace-resource)
2. [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone-for-python?tabs=flask%2Cwindows#start-the-aspire-dashboard)
### Dependencies
You will also need to install the following dependencies to your virtual environment to run this sample:
```
// For Azure ApplicationInsights/AzureMonitor
uv pip install azure-monitor-opentelemetry-exporter==1.0.0b24
// For OTLP endpoint
uv pip install opentelemetry-exporter-otlp-proto-grpc
```
## Running the sample
1. Open a terminal and navigate to this folder: `python/samples/demos/telemetry/`. This is necessary for the `.env` file to be read correctly.
2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example).
> Note that `TELEMETRY_SAMPLE_CONNECTION_STRING` and `OTLP_ENDPOINT` are optional. If you don't configure them, everything will get outputted to the console.
3. Activate your python virtual environment, and then run `python main.py`.
> This will output the Operation/Trace ID, which can be used later for filtering.
### Scenarios
This sample is organized into scenarios where the kernel will generate useful telemetry data:
- `ai_service`: This is when an AI service/connector is invoked directly (i.e. not via any kernel functions or prompts). **Information about the call to the underlying model will be recorded**.
- `kernel_function`: This is when a kernel function is invoked. **Information about the kernel function and the call to the underlying model will be recorded**.
- `auto_function_invocation`: This is when auto function invocation is triggered. **Information about the auto function invocation loop, the kernel functions that are executed, and calls to the underlying model will be recorded**.
By default, running `python main.py` will run all three scenarios. To run individual scenarios, use the `--scenario` command line argument. For example, `python main.py --scenario ai_service`. For more information, please run `python main.py -h`.
## Application Insights/Azure Monitor
### Logs and traces
Go to your Application Insights instance, click on _Transaction search_ on the left menu. Use the operation id output by the program to search for the logs and traces associated with the operation. Click on any of the search result to view the end-to-end transaction details. Read more [here](https://learn.microsoft.com/en-us/azure/azure-monitor/app/transaction-search-and-diagnostics?tabs=transaction-search).
### Metrics
Running the application once will only generate one set of measurements (for each metrics). Run the application a couple times to generate more sets of measurements.
> Note: Make sure not to run the program too frequently. Otherwise, you may get throttled.
Please refer to here on how to analyze metrics in [Azure Monitor](https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/analyze-metrics).
## Aspire Dashboard
> Make sure you have the dashboard running to receive telemetry data.
Once the the sample finishes running, navigate to http://localhost:18888 in a web browser to see the telemetry data. Follow the instructions [here](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/explore) to authenticate to the dashboard and start exploring!
## Console output
You won't have to deploy an Application Insights resource or install Docker to run Aspire Dashboard if you choose to inspect telemetry data in a console. However, it is difficult to navigate through all the spans and logs produced, so **this method is only recommended when you are just getting started**.
We recommend you to get started with the `ai_service` scenario as this generates the least amount of telemetry data. Below is similar to what you will see when you run `python main.py --scenario ai_service`:
```Json
{
"name": "chat.completions gpt-4o",
"context": {
"trace_id": "0xbda1d9efcd65435653d18fa37aef7dd3",
"span_id": "0xcd443e1917510385",
"trace_state": "[]"
},
"kind": "SpanKind.INTERNAL",
"parent_id": "0xeca0a2ca7b7a8191",
"start_time": "2024-09-09T23:13:14.625156Z",
"end_time": "2024-09-09T23:13:17.311909Z",
"status": {
"status_code": "UNSET"
},
"attributes": {
"gen_ai.operation.name": "chat.completions",
"gen_ai.system": "openai",
"gen_ai.request.model": "gpt-4o",
"gen_ai.response.id": "chatcmpl-A5hrG13nhtFsOgx4ziuoskjNscHtT",
"gen_ai.response.finish_reason": "FinishReason.STOP",
"gen_ai.response.prompt_tokens": 16,
"gen_ai.response.completion_tokens": 28
},
"events": [
{
"name": "gen_ai.content.prompt",
"timestamp": "2024-09-09T23:13:14.625156Z",
"attributes": {
"gen_ai.prompt": "[{\"role\": \"user\", \"content\": \"Why is the sky blue in one sentence?\"}]"
}
},
{
"name": "gen_ai.content.completion",
"timestamp": "2024-09-09T23:13:17.311909Z",
"attributes": {
"gen_ai.completion": "[{\"role\": \"assistant\", \"content\": \"The sky appears blue because molecules in the Earth's atmosphere scatter shorter wavelengths of sunlight, such as blue, more effectively than longer wavelengths like red.\"}]"
}
}
],
"links": [],
"resource": {
"attributes": {
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "opentelemetry",
"telemetry.sdk.version": "1.26.0",
"service.name": "TelemetryExample"
},
"schema_url": ""
}
}
{
"name": "Scenario: AI Service",
"context": {
"trace_id": "0xbda1d9efcd65435653d18fa37aef7dd3",
"span_id": "0xeca0a2ca7b7a8191",
"trace_state": "[]"
},
"kind": "SpanKind.INTERNAL",
"parent_id": "0x48af7ad55f2f64b5",
"start_time": "2024-09-09T23:13:14.625156Z",
"end_time": "2024-09-09T23:13:17.312910Z",
"status": {
"status_code": "UNSET"
},
"attributes": {},
"events": [],
"links": [],
"resource": {
"attributes": {
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "opentelemetry",
"telemetry.sdk.version": "1.26.0",
"service.name": "TelemetryExample"
},
"schema_url": ""
}
}
{
"name": "main",
"context": {
"trace_id": "0xbda1d9efcd65435653d18fa37aef7dd3",
"span_id": "0x48af7ad55f2f64b5",
"trace_state": "[]"
},
"kind": "SpanKind.INTERNAL",
"parent_id": null,
"start_time": "2024-09-09T23:13:13.840481Z",
"end_time": "2024-09-09T23:13:17.312910Z",
"status": {
"status_code": "UNSET"
},
"attributes": {},
"events": [],
"links": [],
"resource": {
"attributes": {
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "opentelemetry",
"telemetry.sdk.version": "1.26.0",
"service.name": "TelemetryExample"
},
"schema_url": ""
}
}
{
"body": "OpenAI usage: CompletionUsage(completion_tokens=28, prompt_tokens=16, total_tokens=44)",
"severity_number": "<SeverityNumber.INFO: 9>",
"severity_text": "INFO",
"attributes": {
"code.filepath": "C:\\Users\\taochen\\Projects\\semantic-kernel-fork\\python\\semantic_kernel\\connectors\\ai\\open_ai\\services\\open_ai_handler.py",
"code.function": "store_usage",
"code.lineno": 81
},
"dropped_attributes": 0,
"timestamp": "2024-09-09T23:13:17.311909Z",
"observed_timestamp": "2024-09-09T23:13:17.311909Z",
"trace_id": "0xbda1d9efcd65435653d18fa37aef7dd3",
"span_id": "0xcd443e1917510385",
"trace_flags": 1,
"resource": {
"attributes": {
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "opentelemetry",
"telemetry.sdk.version": "1.26.0",
"service.name": "TelemetryExample"
},
"schema_url": ""
}
}
```
In the output, you will find three spans: `main`, `Scenario: AI Service`, and `chat.completions gpt-4o`, each representing a different layer in the sample. In particular, `chat.completions gpt-4o` is generated by the ai service. Inside it, you will find information about the call, such as the timestamp of the operation, the response id and the finish reason. You will also find sensitive information such as the prompt and response to and from the model (only if you have `SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE` set to true). If you use Application Insights or Aspire Dashboard, these information will be available to you in an interactive UI.
@@ -0,0 +1,34 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Annotated
from semantic_kernel.functions.kernel_function_decorator import kernel_function
###############################
# Plugins for demo purposes ###
###############################
class WeatherPlugin:
"""A demo plugin for getting the weather forecast."""
@kernel_function(name="get_weather", description="Get the weather forecast for a location")
def get_weather(
self,
location: Annotated[str, "The location of interest"],
) -> Annotated[str, "The weather forecast"]:
"""Get the weather forecast for a location.
Args:
location (str): The location.
"""
return f"The weather in {location} is 75°F and sunny."
class LocationPlugin:
"""A demo plugin for getting the location of a place."""
@kernel_function(name="get_current_location", description="Get the current location of the user")
def get_current_location(self) -> Annotated[str, "The current location"]:
"""Get the current location of the user."""
return "Seattle"

Some files were not shown because too many files have changed in this diff Show More