mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e182b610 | ||
|
|
eebd532af9 | ||
|
|
4e421b86fd | ||
|
|
e51edf886b | ||
|
|
837cc16a5e | ||
|
|
943df9e785 |
@@ -81,10 +81,6 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Plugins.OpenApi" Version="1.67.0" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.2.41" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
<PackageVersion Include="Microsoft.Agents.Authentication.Msal" Version="1.2.41" />
|
||||
<PackageVersion Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.2.41" />
|
||||
<!-- A2A -->
|
||||
<PackageVersion Include="A2A" Version="0.3.3-preview" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="0.3.3-preview" />
|
||||
|
||||
@@ -208,9 +208,6 @@
|
||||
<Project Path="samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
|
||||
<Project Path="samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/M365Agent/">
|
||||
<Project Path="samples/M365Agent/M365Agent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".editorconfig" />
|
||||
<File Path=".gitignore" />
|
||||
|
||||
@@ -47,7 +47,7 @@ curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=@dafx-joker@your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
@@ -64,7 +64,7 @@ The expected `application/json` output will look something like:
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"thread_id": "@dafx-joker@your-thread-id",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
|
||||
@@ -52,7 +52,7 @@ The response will be a text string that looks something like the following, indi
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain
|
||||
x-ms-thread-id: 351ec855-7f4d-4527-a60d-498301ced36d
|
||||
x-ms-thread-id: @publisher@351ec855-7f4d-4527-a60d-498301ced36d
|
||||
|
||||
The content generation workflow for the topic "The Future of Artificial Intelligence" has been successfully started, and the instance ID is **6a04276e8d824d8d941e1dc4142cc254**. If you need any further assistance or updates on the workflow, feel free to ask!
|
||||
```
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using AdaptiveCards;
|
||||
using M365Agent.Agents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.Builder;
|
||||
using Microsoft.Agents.Builder.App;
|
||||
using Microsoft.Agents.Builder.State;
|
||||
using Microsoft.Agents.Core.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace M365Agent;
|
||||
|
||||
/// <summary>
|
||||
/// An adapter class that exposes a Microsoft Agent Framework <see cref="AIAgent"/> as a M365 Agent SDK <see cref="AgentApplication"/>.
|
||||
/// </summary>
|
||||
internal sealed class AFAgentApplication : AgentApplication
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly string? _welcomeMessage;
|
||||
|
||||
public AFAgentApplication(AIAgent agent, AgentApplicationOptions options, [FromKeyedServices("AFAgentApplicationWelcomeMessage")] string? welcomeMessage = null) : base(options)
|
||||
{
|
||||
this._agent = agent;
|
||||
this._welcomeMessage = welcomeMessage;
|
||||
|
||||
this.OnConversationUpdate(ConversationUpdateEvents.MembersAdded, this.WelcomeMessageAsync);
|
||||
this.OnActivity(ActivityTypes.Message, this.MessageActivityAsync, rank: RouteRank.Last);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The main agent invocation method, where each user message triggers a call to the underlying <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
private async Task MessageActivityAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
|
||||
{
|
||||
// Start a Streaming Process
|
||||
await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Working on a response for you", cancellationToken);
|
||||
|
||||
// Get the conversation history from turn state.
|
||||
JsonElement threadElementStart = turnState.GetValue<JsonElement>("conversation.chatHistory");
|
||||
|
||||
// Deserialize the conversation history into an AgentThread, or create a new one if none exists.
|
||||
AgentThread agentThread = threadElementStart.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null
|
||||
? this._agent.DeserializeThread(threadElementStart, JsonUtilities.DefaultOptions)
|
||||
: this._agent.GetNewThread();
|
||||
|
||||
ChatMessage chatMessage = HandleUserInput(turnContext);
|
||||
|
||||
// Invoke the WeatherForecastAgent to process the message
|
||||
AgentRunResponse agentRunResponse = await this._agent.RunAsync(chatMessage, agentThread, cancellationToken: cancellationToken);
|
||||
|
||||
// Check for any user input requests in the response
|
||||
// and turn them into adaptive cards in the streaming response.
|
||||
List<Attachment>? attachments = null;
|
||||
HandleUserInputRequests(agentRunResponse, ref attachments);
|
||||
|
||||
// Check for Adaptive Card content in the response messages
|
||||
// and return them appropriately in the response.
|
||||
var adaptiveCards = agentRunResponse.Messages.SelectMany(x => x.Contents).OfType<AdaptiveCardAIContent>().ToList();
|
||||
if (adaptiveCards.Count > 0)
|
||||
{
|
||||
attachments ??= [];
|
||||
attachments.Add(new Attachment()
|
||||
{
|
||||
ContentType = "application/vnd.microsoft.card.adaptive",
|
||||
Content = adaptiveCards.First().AdaptiveCardJson,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
turnContext.StreamingResponse.QueueTextChunk(agentRunResponse.Text);
|
||||
}
|
||||
|
||||
// If created any adaptive cards, add them to the final message.
|
||||
if (attachments is not null)
|
||||
{
|
||||
turnContext.StreamingResponse.FinalMessage = MessageFactory.Attachment(attachments);
|
||||
}
|
||||
|
||||
// Serialize and save the updated conversation history back to turn state.
|
||||
JsonElement threadElementEnd = agentThread.Serialize(JsonUtilities.DefaultOptions);
|
||||
turnState.SetValue("conversation.chatHistory", threadElementEnd);
|
||||
|
||||
// End the streaming response
|
||||
await turnContext.StreamingResponse.EndStreamAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A method to show a welcome message when a new user joins the conversation.
|
||||
/// </summary>
|
||||
private async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this._welcomeMessage))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ChannelAccount member in turnContext.Activity.MembersAdded)
|
||||
{
|
||||
if (member.Id != turnContext.Activity.Recipient.Id)
|
||||
{
|
||||
await turnContext.SendActivityAsync(MessageFactory.Text(this._welcomeMessage), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When a user responds to a function approval request by clicking on a card, this method converts the response
|
||||
/// into the appropriate approval or rejection <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="turnContext">The <see cref="ITurnContext"/> for the current turn.</param>
|
||||
/// <returns>The <see cref="ChatMessage"/> to pass to the <see cref="AIAgent"/>.</returns>
|
||||
private static ChatMessage HandleUserInput(ITurnContext turnContext)
|
||||
{
|
||||
// Check if this contains the function approval Adaptive Card response.
|
||||
if (turnContext.Activity.Value is JsonElement valueElement
|
||||
&& valueElement.GetProperty("type").GetString() == "functionApproval"
|
||||
&& valueElement.GetProperty("approved") is JsonElement approvedJsonElement
|
||||
&& approvedJsonElement.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
&& valueElement.GetProperty("requestJson") is JsonElement requestJsonElement
|
||||
&& requestJsonElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var requestContent = JsonSerializer.Deserialize<FunctionApprovalRequestContent>(requestJsonElement.GetString()!, JsonUtilities.DefaultOptions);
|
||||
|
||||
return new ChatMessage(ChatRole.User, [requestContent!.CreateResponse(approvedJsonElement.ValueKind == JsonValueKind.True)]);
|
||||
}
|
||||
|
||||
return new ChatMessage(ChatRole.User, turnContext.Activity.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the agent returns any user input requests, this method converts them into adaptive cards that
|
||||
/// asks the user to approve or deny the requests.
|
||||
/// </summary>
|
||||
/// <param name="response">The <see cref="AgentRunResponse"/> that may contain the user input requests.</param>
|
||||
/// <param name="attachments">The list of <see cref="Attachment"/> to which the adaptive cards will be added.</param>
|
||||
private static void HandleUserInputRequests(AgentRunResponse response, ref List<Attachment>? attachments)
|
||||
{
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
if (userInputRequests.Count > 0)
|
||||
{
|
||||
foreach (var functionApprovalRequest in userInputRequests.OfType<FunctionApprovalRequestContent>())
|
||||
{
|
||||
var functionApprovalRequestJson = JsonSerializer.Serialize(functionApprovalRequest, JsonUtilities.DefaultOptions);
|
||||
|
||||
var card = new AdaptiveCard("1.5");
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = "Function Call Approval Required",
|
||||
Size = AdaptiveTextSize.Large,
|
||||
Weight = AdaptiveTextWeight.Bolder,
|
||||
HorizontalAlignment = AdaptiveHorizontalAlignment.Center
|
||||
});
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = $"Function: {functionApprovalRequest.FunctionCall.Name}"
|
||||
});
|
||||
card.Body.Add(new AdaptiveActionSet()
|
||||
{
|
||||
Actions =
|
||||
[
|
||||
new AdaptiveSubmitAction
|
||||
{
|
||||
Id = "Approve",
|
||||
Title = "Approve",
|
||||
Data = new { type = "functionApproval", approved = true, requestJson = functionApprovalRequestJson }
|
||||
},
|
||||
new AdaptiveSubmitAction
|
||||
{
|
||||
Id = "Deny",
|
||||
Title = "Deny",
|
||||
Data = new { type = "functionApproval", approved = false, requestJson = functionApprovalRequestJson }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new Attachment()
|
||||
{
|
||||
ContentType = "application/vnd.microsoft.card.adaptive",
|
||||
Content = card.ToJson(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using AdaptiveCards;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace M365Agent.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContent"/> type allows an <see cref="AIAgent"/> to return adaptive cards as part of its response messages.
|
||||
/// </summary>
|
||||
internal sealed class AdaptiveCardAIContent : AIContent
|
||||
{
|
||||
public AdaptiveCardAIContent(AdaptiveCard adaptiveCard)
|
||||
{
|
||||
this.AdaptiveCard = adaptiveCard ?? throw new ArgumentNullException(nameof(adaptiveCard));
|
||||
}
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
[JsonConstructor]
|
||||
public AdaptiveCardAIContent(string adaptiveCardJson)
|
||||
{
|
||||
this.AdaptiveCardJson = adaptiveCardJson;
|
||||
}
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
[JsonIgnore]
|
||||
public AdaptiveCard AdaptiveCard { get; private set; }
|
||||
|
||||
public string AdaptiveCardJson
|
||||
{
|
||||
get => this.AdaptiveCard.ToJson();
|
||||
set => this.AdaptiveCard = AdaptiveCard.FromJson(value).Card;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using AdaptiveCards;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace M365Agent.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// A weather forecasting agent. This agent wraps a <see cref="ChatClientAgent"/> and adds custom logic
|
||||
/// to generate adaptive cards for weather forecasts and add these to the agent's response.
|
||||
/// </summary>
|
||||
public class WeatherForecastAgent : DelegatingAIAgent
|
||||
{
|
||||
private const string AgentName = "WeatherForecastAgent";
|
||||
private const string AgentInstructions = """
|
||||
You are a friendly assistant that helps people find a weather forecast for a given location.
|
||||
You may ask follow up questions until you have enough information to answer the customers question.
|
||||
When answering with a weather forecast, fill out the weatherCard property with an adaptive card containing the weather information and
|
||||
add some emojis to indicate the type of weather.
|
||||
When answering with just text, fill out the context property with a friendly response.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WeatherForecastAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">An instance of <see cref="IChatClient"/> for interacting with an LLM.</param>
|
||||
public WeatherForecastAgent(IChatClient chatClient)
|
||||
: base(new ChatClientAgent(
|
||||
chatClient: chatClient,
|
||||
new ChatClientAgentOptions()
|
||||
{
|
||||
Name = AgentName,
|
||||
Instructions = AgentInstructions,
|
||||
ChatOptions = new ChatOptions()
|
||||
{
|
||||
Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))],
|
||||
// We want the agent to return structured output in a known format
|
||||
// so that we can easily create adaptive cards from the response.
|
||||
ResponseFormat = ChatResponseFormat.ForJsonSchema(
|
||||
schema: AIJsonUtilities.CreateJsonSchema(typeof(WeatherForecastAgentResponse)),
|
||||
schemaName: "WeatherForecastAgentResponse",
|
||||
schemaDescription: "Response to a query about the weather in a specified location"),
|
||||
}
|
||||
}))
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await base.RunAsync(messages, thread, options, cancellationToken);
|
||||
|
||||
// If the agent returned a valid structured output response
|
||||
// we might be able to enhance the response with an adaptive card.
|
||||
if (response.TryDeserialize<WeatherForecastAgentResponse>(JsonSerializerOptions.Web, out var structuredOutput))
|
||||
{
|
||||
var textContentMessage = response.Messages.FirstOrDefault(x => x.Contents.OfType<TextContent>().Any());
|
||||
if (textContentMessage is not null)
|
||||
{
|
||||
// If the response contains weather information, create an adaptive card.
|
||||
if (structuredOutput.ContentType == WeatherForecastAgentResponseContentType.WeatherForecastAgentResponse)
|
||||
{
|
||||
var card = CreateWeatherCard(structuredOutput.Location, structuredOutput.MeteorologicalCondition, structuredOutput.TemperatureInCelsius);
|
||||
textContentMessage.Contents.Add(new AdaptiveCardAIContent(card));
|
||||
}
|
||||
|
||||
// If the response is just text, replace the structured output with the text response.
|
||||
if (structuredOutput.ContentType == WeatherForecastAgentResponseContentType.OtherAgentResponse)
|
||||
{
|
||||
var textContent = textContentMessage.Contents.OfType<TextContent>().First();
|
||||
textContent.Text = structuredOutput.OtherResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mock weather tool, to get weather information for a given location.
|
||||
/// </summary>
|
||||
[Description("Get the weather for a given location.")]
|
||||
private static string GetWeather([Description("The location to get the weather for.")] string location)
|
||||
=> $"The weather in {location} is cloudy with a high of 15°C.";
|
||||
|
||||
/// <summary>
|
||||
/// Create an adaptive card to display weather information.
|
||||
/// </summary>
|
||||
private static AdaptiveCard CreateWeatherCard(string? location, string? condition, string? temperature)
|
||||
{
|
||||
var card = new AdaptiveCard("1.5");
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = "🌤️ Weather Forecast 🌤️",
|
||||
Size = AdaptiveTextSize.Large,
|
||||
Weight = AdaptiveTextWeight.Bolder,
|
||||
HorizontalAlignment = AdaptiveHorizontalAlignment.Center
|
||||
});
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = "Location: " + location,
|
||||
});
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = "Condition: " + condition,
|
||||
});
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = "Temperature: " + temperature,
|
||||
});
|
||||
return card;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace M365Agent.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// The structured output type for the <see cref="WeatherForecastAgent"/>.
|
||||
/// </summary>
|
||||
internal sealed class WeatherForecastAgentResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// A value indicating whether the response contains a weather forecast or some other type of response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contentType")]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public WeatherForecastAgentResponseContentType ContentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If the agent could not provide a weather forecast this should contain a textual response.
|
||||
/// </summary>
|
||||
[Description("If the answer is other agent response, contains the textual agent response.")]
|
||||
[JsonPropertyName("otherResponse")]
|
||||
public string? OtherResponse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The location for which the weather forecast is given.
|
||||
/// </summary>
|
||||
[Description("If the answer is a weather forecast, contains the location for which the forecast is given.")]
|
||||
[JsonPropertyName("location")]
|
||||
public string? Location { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The temperature in Celsius for the given location.
|
||||
/// </summary>
|
||||
[Description("If the answer is a weather forecast, contains the temperature in Celsius.")]
|
||||
[JsonPropertyName("temperatureInCelsius")]
|
||||
public string? TemperatureInCelsius { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The meteorological condition for the given location.
|
||||
/// </summary>
|
||||
[Description("If the answer is a weather forecast, contains the meteorological condition (e.g., Sunny, Rainy).")]
|
||||
[JsonPropertyName("meteorologicalCondition")]
|
||||
public string? MeteorologicalCondition { get; set; }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace M365Agent.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// The type of content contained in a <see cref="WeatherForecastAgentResponse"/>.
|
||||
/// </summary>
|
||||
internal enum WeatherForecastAgentResponseContentType
|
||||
{
|
||||
[JsonPropertyName("otherAgentResponse")]
|
||||
OtherAgentResponse,
|
||||
|
||||
[JsonPropertyName("weatherForecastAgentResponse")]
|
||||
WeatherForecastAgentResponse
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using Microsoft.Agents.Authentication;
|
||||
using Microsoft.Agents.Core;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.IdentityModel.Validators;
|
||||
|
||||
namespace M365Agent;
|
||||
|
||||
internal static class AspNetExtensions
|
||||
{
|
||||
private static readonly CompositeFormat s_cachedValidTokenIssuerUrlTemplateV1Format = CompositeFormat.Parse(AuthenticationConstants.ValidTokenIssuerUrlTemplateV1);
|
||||
private static readonly CompositeFormat s_cachedValidTokenIssuerUrlTemplateV2Format = CompositeFormat.Parse(AuthenticationConstants.ValidTokenIssuerUrlTemplateV2);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> s_openIdMetadataCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent using settings in configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to resolve dependencies.</param>
|
||||
/// <param name="configuration">Used to read configuration settings.</param>
|
||||
/// <param name="tokenValidationSectionName">Name of the config section to read.</param>
|
||||
/// <remarks>
|
||||
/// <para>This extension reads <see cref="TokenValidationOptions"/> settings from configuration. If configuration is missing JWT token
|
||||
/// is not enabled.</para>
|
||||
/// <p>The minimum, but typical, configuration is:</p>
|
||||
/// <code>
|
||||
/// "TokenValidation": {
|
||||
/// "Enabled": boolean,
|
||||
/// "Audiences": [
|
||||
/// "{{ClientId}}" // this is the Client ID used for the Azure Bot
|
||||
/// ],
|
||||
/// "TenantId": "{{TenantId}}"
|
||||
/// }
|
||||
/// </code>
|
||||
/// <para>The full options are:</para>
|
||||
/// <code>
|
||||
/// "TokenValidation": {
|
||||
/// "Enabled": boolean,
|
||||
/// "Audiences": [
|
||||
/// "{required:agent-appid}"
|
||||
/// ],
|
||||
/// "TenantId": "{recommended:tenant-id}",
|
||||
/// "ValidIssuers": [
|
||||
/// "{default:Public-AzureBotService}"
|
||||
/// ],
|
||||
/// "IsGov": {optional:false},
|
||||
/// "AzureBotServiceOpenIdMetadataUrl": optional,
|
||||
/// "OpenIdMetadataUrl": optional,
|
||||
/// "AzureBotServiceTokenHandling": "{optional:true}"
|
||||
/// "OpenIdMetadataRefresh": "optional-12:00:00"
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation")
|
||||
{
|
||||
IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName);
|
||||
|
||||
if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true))
|
||||
{
|
||||
// Noop if TokenValidation section missing or disabled.
|
||||
System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
services.AddAgentAspNetAuthentication(tokenValidationSection.Get<TokenValidationOptions>()!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent.
|
||||
/// </summary>
|
||||
public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions)
|
||||
{
|
||||
AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions));
|
||||
|
||||
// Must have at least one Audience.
|
||||
if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0)
|
||||
{
|
||||
throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId");
|
||||
}
|
||||
|
||||
// Audience values must be GUID's
|
||||
foreach (var audience in validationOptions.Audiences)
|
||||
{
|
||||
if (!Guid.TryParse(audience, out _))
|
||||
{
|
||||
throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID");
|
||||
}
|
||||
}
|
||||
|
||||
// If ValidIssuers is empty, default for ABS Public Cloud
|
||||
if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0)
|
||||
{
|
||||
validationOptions.ValidIssuers =
|
||||
[
|
||||
"https://api.botframework.com",
|
||||
"https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
|
||||
"https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0",
|
||||
"https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/",
|
||||
"https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0",
|
||||
"https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/",
|
||||
"https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0",
|
||||
];
|
||||
|
||||
if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _))
|
||||
{
|
||||
validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, s_cachedValidTokenIssuerUrlTemplateV1Format, validationOptions.TenantId));
|
||||
validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, s_cachedValidTokenIssuerUrlTemplateV2Format, validationOptions.TenantId));
|
||||
}
|
||||
}
|
||||
|
||||
// If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens.
|
||||
if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl))
|
||||
{
|
||||
validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl;
|
||||
}
|
||||
|
||||
// If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens.
|
||||
if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl))
|
||||
{
|
||||
validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl;
|
||||
}
|
||||
|
||||
var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval;
|
||||
|
||||
_ = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.SaveToken = true;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5),
|
||||
ValidIssuers = validationOptions.ValidIssuers,
|
||||
ValidAudiences = validationOptions.Audiences,
|
||||
ValidateIssuerSigningKey = true,
|
||||
RequireSignedTokens = true,
|
||||
};
|
||||
|
||||
// Using Microsoft.IdentityModel.Validators
|
||||
options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation();
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
// Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens.
|
||||
OnMessageReceived = async context =>
|
||||
{
|
||||
string authorizationHeader = context.Request.Headers.Authorization.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
{
|
||||
// Default to AadTokenValidation handling
|
||||
context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager;
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
string[] parts = authorizationHeader.Split(' ')!;
|
||||
if (parts.Length != 2 || parts[0] != "Bearer")
|
||||
{
|
||||
// Default to AadTokenValidation handling
|
||||
context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager;
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
JwtSecurityToken token = new(parts[1]);
|
||||
string issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value!;
|
||||
|
||||
string openIdMetadataUrl = (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer, StringComparison.Ordinal))
|
||||
? validationOptions.AzureBotServiceOpenIdMetadataUrl
|
||||
: validationOptions.OpenIdMetadataUrl;
|
||||
|
||||
context.Options.TokenValidationParameters.ConfigurationManager = s_openIdMetadataCache.GetOrAdd(openIdMetadataUrl, key =>
|
||||
{
|
||||
return new ConfigurationManager<OpenIdConnectConfiguration>(openIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient())
|
||||
{
|
||||
AutomaticRefreshInterval = openIdMetadataRefresh
|
||||
};
|
||||
});
|
||||
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
},
|
||||
|
||||
OnTokenValidated = context => Task.CompletedTask,
|
||||
OnForbidden = context => Task.CompletedTask,
|
||||
OnAuthenticationFailed = context => Task.CompletedTask
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.Authentication;
|
||||
|
||||
namespace M365Agent;
|
||||
|
||||
internal sealed class TokenValidationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The list of audiences to validate against.
|
||||
/// </summary>
|
||||
public IList<string>? Audiences { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TenantId of the Azure Bot. Optional but recommended.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional valid issuers. Optional, in which case the Public Azure Bot Service issuers are used.
|
||||
/// </summary>
|
||||
public IList<string>? ValidIssuers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used.
|
||||
/// </summary>
|
||||
public bool IsGov { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Azure Bot Service OpenIdMetadataUrl. Optional, in which case default value depends on IsGov.
|
||||
/// </summary>
|
||||
/// <see cref="AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl"/>
|
||||
/// <see cref="AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl"/>
|
||||
public string? AzureBotServiceOpenIdMetadataUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Entra OpenIdMetadataUrl. Optional, in which case default value depends on IsGov.
|
||||
/// </summary>
|
||||
/// <see cref="AuthenticationConstants.PublicOpenIdMetadataUrl"/>
|
||||
/// <see cref="AuthenticationConstants.GovOpenIdMetadataUrl"/>
|
||||
public string? OpenIdMetadataUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines if Azure Bot Service tokens are handled. Defaults to true and should always be true until Azure Bot Service sends Entra ID token.
|
||||
/// </summary>
|
||||
public bool AzureBotServiceTokenHandling { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// OpenIdMetadata refresh interval. Defaults to 12 hours.
|
||||
/// </summary>
|
||||
public TimeSpan? OpenIdMetadataRefresh { get; set; }
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using M365Agent.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace M365Agent;
|
||||
|
||||
/// <summary>Provides a collection of utility methods for working with JSON data in the context of the application.</summary>
|
||||
internal static partial class JsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
|
||||
/// includes source generated contracts for all common exchange types contained in this library.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It additionally turns on the following settings:
|
||||
/// <list type="number">
|
||||
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
|
||||
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
|
||||
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Creates default options to use for agents-related serialization.
|
||||
/// </summary>
|
||||
/// <returns>The configured options.</returns>
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
|
||||
private static JsonSerializerOptions CreateDefaultOptions()
|
||||
{
|
||||
// Copy the configuration from the source generated context.
|
||||
JsonSerializerOptions options = new(JsonContext.Default.Options)
|
||||
{
|
||||
// Chain in the resolvers from both AgentAbstractionsJsonUtilities and our source generated context.
|
||||
// We want AgentAbstractionsJsonUtilities first to ensure any M.E.AI types are handled via its resolver.
|
||||
TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default),
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as in AgentAbstractionsJsonUtilities and AIJsonUtilities
|
||||
};
|
||||
options.AddAIContentType<AdaptiveCardAIContent>(typeDiscriminatorId: "adaptiveCard");
|
||||
|
||||
if (JsonSerializer.IsReflectionEnabledByDefault)
|
||||
{
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
}
|
||||
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
|
||||
// Keep in sync with CreateDefaultOptions above.
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
|
||||
// M365Agent specific types
|
||||
[JsonSerializable(typeof(AdaptiveCardAIContent))]
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>b842df34-390f-490d-9dc0-73909363ad16</UserSecretsId>
|
||||
<NoWarn>$(NoWarn);CA1812</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json.template" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AdaptiveCards" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.Authentication.Msal" />
|
||||
<PackageReference Include="Microsoft.Agents.Hosting.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample that shows how to create an Agent Framework agent that is hosted using the M365 Agent SDK.
|
||||
// The agent can then be consumed from various M365 channels.
|
||||
// See the README.md for more information.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using M365Agent;
|
||||
using M365Agent.Agents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.Builder;
|
||||
using Microsoft.Agents.Hosting.AspNetCore;
|
||||
using Microsoft.Agents.Storage;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OpenAI;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Configuration.AddUserSecrets<Program>();
|
||||
}
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Register the inference service of your choice. AzureOpenAI and OpenAI are demonstrated...
|
||||
IChatClient chatClient;
|
||||
if (builder.Configuration.GetSection("AIServices").GetValue<bool>("UseAzureOpenAI"))
|
||||
{
|
||||
var deploymentName = builder.Configuration.GetSection("AIServices:AzureOpenAI").GetValue<string>("DeploymentName")!;
|
||||
var endpoint = builder.Configuration.GetSection("AIServices:AzureOpenAI").GetValue<string>("Endpoint")!;
|
||||
|
||||
chatClient = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient();
|
||||
}
|
||||
else
|
||||
{
|
||||
var modelId = builder.Configuration.GetSection("AIServices:OpenAI").GetValue<string>("ModelId")!;
|
||||
var apiKey = builder.Configuration.GetSection("AIServices:OpenAI").GetValue<string>("ApiKey")!;
|
||||
|
||||
chatClient = new OpenAIClient(
|
||||
apiKey)
|
||||
.GetChatClient(modelId)
|
||||
.AsIChatClient();
|
||||
}
|
||||
builder.Services.AddSingleton(chatClient);
|
||||
|
||||
// Add AgentApplicationOptions from appsettings section "AgentApplication".
|
||||
builder.AddAgentApplicationOptions();
|
||||
|
||||
// Add the WeatherForecastAgent plus a welcome message.
|
||||
// These will be consumed by the AFAgentApplication and exposed as an Agent SDK AgentApplication.
|
||||
builder.Services.AddSingleton<AIAgent, WeatherForecastAgent>();
|
||||
builder.Services.AddKeyedSingleton("AFAgentApplicationWelcomeMessage", "Hello and Welcome! I'm here to help with all your weather forecast needs!");
|
||||
|
||||
// Add the AgentApplication, which contains the logic for responding to
|
||||
// user messages via the Agent SDK.
|
||||
builder.AddAgent<AFAgentApplication>();
|
||||
|
||||
// Register IStorage. For development, MemoryStorage is suitable.
|
||||
// For production Agents, persisted storage should be used so
|
||||
// that state survives Agent restarts, and operates correctly
|
||||
// in a cluster of Agent instances.
|
||||
builder.Services.AddSingleton<IStorage, MemoryStorage>();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
// Add AspNet token validation for Azure Bot Service and Entra. Authentication is
|
||||
// configured in the appsettings.json "TokenValidation" section.
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddAgentAspNetAuthentication(builder.Configuration);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
// Enable AspNet authentication and authorization
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet("/", () => "Microsoft Agents SDK Sample");
|
||||
|
||||
// This receives incoming messages and routes them to the registered AgentApplication.
|
||||
var incomingRoute = app.MapPost("/api/messages", async (HttpRequest request, HttpResponse response, IAgentHttpAdapter adapter, IAgent agent, CancellationToken cancellationToken) => await adapter.ProcessAsync(request, response, agent, cancellationToken));
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
incomingRoute.RequireAuthorization();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hardcoded for brevity and ease of testing.
|
||||
// In production, this should be set in configuration.
|
||||
app.Urls.Add("http://localhost:3978");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"profiles": {
|
||||
"M365Agent": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:49692;http://localhost:49693"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
# Microsoft Agent Framework agents with the M365 Agents SDK Weather Agent sample
|
||||
|
||||
This is a sample of a simple Weather Forecast Agent that is hosted on an Asp.Net core web service and is exposed via the M365 Agent SDK. This Agent is configured to accept a request asking for information about a weather forecast and respond to the caller with an Adaptive Card. This agent will handle multiple "turns" to get the required information from the user.
|
||||
|
||||
This Agent Sample is intended to introduce you the basics of integrating Agent Framework with the Microsoft 365 Agents SDK in order to use Agent Framework agents in various M365 services and applications. It can also be used as the base for a custom Agent that you choose to develop.
|
||||
|
||||
***Note:*** This sample requires JSON structured output from the model which works best from newer versions of the model such as gpt-4o-mini.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 8.0 SDK or later](https://dotnet.microsoft.com/download)
|
||||
- [devtunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows)
|
||||
- [Microsoft 365 Agents Toolkit](https://github.com/OfficeDev/microsoft-365-agents-toolkit)
|
||||
|
||||
- You will need an Azure OpenAI or OpenAI resource using `gpt-4o-mini`
|
||||
|
||||
- Configure OpenAI in appsettings
|
||||
|
||||
```json
|
||||
"AIServices": {
|
||||
"AzureOpenAI": {
|
||||
"DeploymentName": "", // This is the Deployment (as opposed to model) Name of the Azure OpenAI model
|
||||
"Endpoint": "", // This is the Endpoint of the Azure OpenAI resource
|
||||
"ApiKey": "" // This is the API Key of the Azure OpenAI resource. Optional, uses AzureCliCredential if not provided
|
||||
},
|
||||
"OpenAI": {
|
||||
"ModelId": "", // This is the Model ID of the OpenAI model
|
||||
"ApiKey": "" // This is your API Key for the OpenAI service
|
||||
},
|
||||
"UseAzureOpenAI": false // This is a flag to determine whether to use the Azure OpenAI or the OpenAI service
|
||||
}
|
||||
```
|
||||
|
||||
## QuickStart using Agent Toolkit
|
||||
1. If you haven't done so already, install the Agents Playground
|
||||
|
||||
```
|
||||
winget install agentsplayground
|
||||
```
|
||||
1. Start the sample application.
|
||||
1. Start Agents Playground. At a command prompt: `agentsplayground`
|
||||
- The tool will open a web browser showing the Microsoft 365 Agents Playground, ready to send messages to your agent.
|
||||
1. Interact with the Agent via the browser
|
||||
|
||||
## QuickStart using WebChat or Teams
|
||||
|
||||
- Overview of running and testing an Agent
|
||||
- Provision an Azure Bot in your Azure Subscription
|
||||
- Configure your Agent settings to use to desired authentication type
|
||||
- Running an instance of the Agent app (either locally or deployed to Azure)
|
||||
- Test in a client
|
||||
|
||||
1. Create an Azure Bot with one of these authentication types
|
||||
- [SingleTenant, Client Secret](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/azure-bot-create-single-secret)
|
||||
- [SingleTenant, Federated Credentials](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/azure-bot-create-federated-credentials)
|
||||
- [User Assigned Managed Identity](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/azure-bot-create-managed-identity)
|
||||
|
||||
> Be sure to follow the **Next Steps** at the end of these docs to configure your agent settings.
|
||||
|
||||
> **IMPORTANT:** If you want to run your agent locally via devtunnels, the only support auth type is ClientSecret and Certificates
|
||||
|
||||
1. Running the Agent
|
||||
1. Running the Agent locally
|
||||
- Requires a tunneling tool to allow for local development and debugging should you wish to do local development whilst connected to a external client such as Microsoft Teams.
|
||||
- **For ClientSecret or Certificate authentication types only.** Federated Credentials and Managed Identity will not work via a tunnel to a local agent and must be deployed to an App Service or container.
|
||||
|
||||
1. Run `devtunnel`. Please follow [Create and host a dev tunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows) and host the tunnel with anonymous user access command as shown below:
|
||||
|
||||
```bash
|
||||
devtunnel host -p 3978 --allow-anonymous
|
||||
```
|
||||
|
||||
1. On the Azure Bot, select **Settings**, then **Configuration**, and update the **Messaging endpoint** to `{tunnel-url}/api/messages`
|
||||
|
||||
1. Start the Agent in Visual Studio
|
||||
|
||||
1. Deploy Agent code to Azure
|
||||
1. VS Publish works well for this. But any tools used to deploy a web application will also work.
|
||||
1. On the Azure Bot, select **Settings**, then **Configuration**, and update the **Messaging endpoint** to `https://{{appServiceDomain}}/api/messages`
|
||||
|
||||
## Testing this agent with WebChat
|
||||
|
||||
1. Select **Test in WebChat** under **Settings** on the Azure Bot in the Azure Portal
|
||||
|
||||
## Testing this Agent in Teams or M365
|
||||
|
||||
1. Update the manifest.json
|
||||
- Edit the `manifest.json` contained in the `/appManifest` folder
|
||||
- Replace with your AppId (that was created above) *everywhere* you see the place holder string `<<AAD_APP_CLIENT_ID>>`
|
||||
- Replace `<<BOT_DOMAIN>>` with your Agent url. For example, the tunnel host name.
|
||||
- Zip up the contents of the `/appManifest` folder to create a `manifest.zip`
|
||||
- `manifest.json`
|
||||
- `outline.png`
|
||||
- `color.png`
|
||||
|
||||
1. Your Azure Bot should have the **Microsoft Teams** channel added under **Channels**.
|
||||
|
||||
1. Navigate to the Microsoft Admin Portal (MAC). Under **Settings** and **Integrated Apps,** select **Upload Custom App**.
|
||||
|
||||
1. Select the `manifest.zip` created in the previous step.
|
||||
|
||||
1. After a short period of time, the agent shows up in Microsoft Teams and Microsoft 365 Copilot.
|
||||
|
||||
## Enabling JWT token validation
|
||||
1. By default, the AspNet token validation is disabled in order to support local debugging.
|
||||
1. Enable by updating appsettings
|
||||
```json
|
||||
"TokenValidation": {
|
||||
"Enabled": true,
|
||||
"Audiences": [
|
||||
"{{ClientId}}" // this is the Client ID used for the Azure Bot
|
||||
],
|
||||
"TenantId": "{{TenantId}}"
|
||||
},
|
||||
```
|
||||
|
||||
## Further reading
|
||||
|
||||
To learn more about using the M365 Agent SDK, see [Microsoft 365 Agents SDK](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/).
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.22/MicrosoftTeams.schema.json",
|
||||
"manifestVersion": "1.22",
|
||||
"version": "1.0.0",
|
||||
"id": "<<AAD_APP_CLIENT_ID>>",
|
||||
"developer": {
|
||||
"name": "Microsoft, Inc.",
|
||||
"websiteUrl": "https://example.azurewebsites.net",
|
||||
"privacyUrl": "https://example.azurewebsites.net/privacy",
|
||||
"termsOfUseUrl": "https://example.azurewebsites.net/termsofuse"
|
||||
},
|
||||
"icons": {
|
||||
"color": "color.png",
|
||||
"outline": "outline.png"
|
||||
},
|
||||
"name": {
|
||||
"short": "AF Sample Agent",
|
||||
"full": "M365 AgentSDK and Microsoft Agent Framework Sample"
|
||||
},
|
||||
"description": {
|
||||
"short": "Sample demonstrating M365 AgentSDK, Teams, and Microsoft Agent Framework",
|
||||
"full": "Sample demonstrating M365 AgentSDK, Teams, and Microsoft Agent Framework"
|
||||
},
|
||||
"accentColor": "#FFFFFF",
|
||||
"copilotAgents": {
|
||||
"customEngineAgents": [
|
||||
{
|
||||
"id": "<<AAD_APP_CLIENT_ID>>",
|
||||
"type": "bot"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bots": [
|
||||
{
|
||||
"botId": "<<AAD_APP_CLIENT_ID>>",
|
||||
"scopes": [
|
||||
"personal"
|
||||
],
|
||||
"supportsFiles": false,
|
||||
"isNotificationOnly": false
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
"identity",
|
||||
"messageTeamMembers"
|
||||
],
|
||||
"validDomains": [
|
||||
"<<BOT_DOMAIN>>"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 407 B |
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"TokenValidation": {
|
||||
"Enabled": false,
|
||||
"Audiences": [
|
||||
"{{ClientId}}" // this is the Client ID used for the Azure Bot
|
||||
],
|
||||
"TenantId": "{{TenantId}}"
|
||||
},
|
||||
|
||||
"AgentApplication": {
|
||||
"StartTypingTimer": true,
|
||||
"RemoveRecipientMention": false,
|
||||
"NormalizeMentions": false
|
||||
},
|
||||
|
||||
"Connections": {
|
||||
"ServiceConnection": {
|
||||
"Settings": {
|
||||
// this is the AuthType for the connection, valid values can be found in Microsoft.Agents.Authentication.Msal.Model.AuthTypes. The default is ClientSecret.
|
||||
"AuthType": ""
|
||||
|
||||
// Other properties dependent on the authorization type the Azure Bot uses.
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConnectionsMap": [
|
||||
{
|
||||
"ServiceUrl": "*",
|
||||
"Connection": "ServiceConnection"
|
||||
}
|
||||
],
|
||||
|
||||
// This is the configuration for the AI services, use environment variables or user secrets to store sensitive information.
|
||||
// Do not store sensitive information in this file
|
||||
"AIServices": {
|
||||
"AzureOpenAI": {
|
||||
"DeploymentName": "", // This is the Deployment (as opposed to model) Name of the Azure OpenAI model
|
||||
"Endpoint": "", // This is the Endpoint of the Azure OpenAI resource
|
||||
"ApiKey": "" // This is the API Key of the Azure OpenAI resource. Optional, uses AzureCliCredential if not provided
|
||||
},
|
||||
"OpenAI": {
|
||||
"ModelId": "", // This is the Model ID of the OpenAI model
|
||||
"ApiKey": "" // This is your API Key for the OpenAI service
|
||||
},
|
||||
"UseAzureOpenAI": false // This is a flag to determine whether to use the Azure OpenAI or the OpenAI service
|
||||
},
|
||||
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,13 +83,12 @@ internal static class BuiltInFunctions
|
||||
|
||||
string? threadIdValue = threadIdFromBody ?? threadIdFromQuery;
|
||||
|
||||
// The thread_id is treated as a session key (not a full session ID).
|
||||
// If no session key is provided, use the function invocation ID as the session key
|
||||
// to help correlate the session with the function invocation.
|
||||
string agentName = GetAgentName(context);
|
||||
// If no session ID is provided, use a new one based on the function name and invocation ID.
|
||||
// This may be better than a random one because it can be correlated with the function invocation.
|
||||
// Specifying a session ID is how the caller correlates multiple calls to the same agent session.
|
||||
AgentSessionId sessionId = string.IsNullOrEmpty(threadIdValue)
|
||||
? new AgentSessionId(agentName, context.InvocationId)
|
||||
: new AgentSessionId(agentName, threadIdValue);
|
||||
? new AgentSessionId(GetAgentName(context), context.InvocationId)
|
||||
: AgentSessionId.Parse(threadIdValue);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
@@ -111,7 +110,7 @@ internal static class BuiltInFunctions
|
||||
}
|
||||
}
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName);
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, GetAgentName(context));
|
||||
|
||||
DurableAgentRunOptions options = new() { IsFireAndForget = !waitForResponse };
|
||||
|
||||
@@ -127,7 +126,7 @@ internal static class BuiltInFunctions
|
||||
req,
|
||||
context,
|
||||
HttpStatusCode.OK,
|
||||
sessionId.Key,
|
||||
sessionId.ToString(),
|
||||
agentResponse);
|
||||
}
|
||||
|
||||
@@ -141,7 +140,7 @@ internal static class BuiltInFunctions
|
||||
return await CreateAcceptedResponseAsync(
|
||||
req,
|
||||
context,
|
||||
sessionId.Key);
|
||||
sessionId.ToString());
|
||||
}
|
||||
|
||||
public static async Task<string?> RunMcpToolAsync(
|
||||
|
||||
@@ -13,6 +13,7 @@ internal interface ISuperStepJoinContext
|
||||
|
||||
ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default);
|
||||
ValueTask SendMessageAsync<TMessage>(string senderId, [DisallowNull] TMessage message, CancellationToken cancellationToken = default);
|
||||
ValueTask YieldOutputAsync(string sourceId, object output, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<string> AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default);
|
||||
ValueTask<bool> DetachSuperstepAsync(string id);
|
||||
|
||||
@@ -429,4 +429,21 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
|
||||
ValueTask ISuperStepJoinContext.SendMessageAsync<TMessage>(string senderId, [DisallowNull] TMessage message, CancellationToken cancellationToken)
|
||||
=> this.SendMessageAsync(senderId, Throw.IfNull(message), cancellationToken: cancellationToken);
|
||||
|
||||
async ValueTask ISuperStepJoinContext.YieldOutputAsync(string sourceId, object output, CancellationToken cancellationToken)
|
||||
{
|
||||
this.CheckEnded();
|
||||
Throw.IfNull(output);
|
||||
|
||||
Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
|
||||
if (!sourceExecutor.CanOutput(output.GetType()))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot output object of type {output.GetType().Name}. Expecting one of [{string.Join(", ", sourceExecutor.OutputTypes)}].");
|
||||
}
|
||||
|
||||
if (this._outputFilter.CanOutput(sourceId, output))
|
||||
{
|
||||
await this.AddEventAsync(new WorkflowOutputEvent(output, sourceId), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +192,16 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
|
||||
resultTask = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowErrorEvent(this.Id, errorEvent.Data as Exception)).AsTask() ?? Task.CompletedTask;
|
||||
break;
|
||||
case WorkflowOutputEvent outputEvent:
|
||||
if (this._joinContext != null &&
|
||||
this._options.AutoSendMessageHandlerResultObject
|
||||
&& outputEvent.Data != null)
|
||||
if (this._joinContext != null && outputEvent.Data != null)
|
||||
{
|
||||
resultTask = this._joinContext.SendMessageAsync(this.Id, outputEvent.Data).AsTask();
|
||||
if (this._options.AutoSendMessageHandlerResultObject)
|
||||
{
|
||||
resultTask = this._joinContext.SendMessageAsync(this.Id, outputEvent.Data).AsTask();
|
||||
}
|
||||
else if (this._options.AutoYieldOutputHandlerResultObject)
|
||||
{
|
||||
resultTask = this._joinContext.YieldOutputAsync(this.Id, outputEvent.Data).AsTask();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case RequestHaltEvent requestHaltEvent:
|
||||
|
||||
@@ -75,8 +75,8 @@ public class ChatClientAgentThread : AgentThread
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Note that either <see cref="ConversationId"/> or <see cref="MessageStore "/> may be set, but not both.
|
||||
/// If <see cref="MessageStore "/> is not null, setting <see cref="ConversationId"/> will throw an
|
||||
/// <see cref="InvalidOperationException "/> exception.
|
||||
/// If <see cref="MessageStore "/> is not null, and <see cref="ConversationId"/> is set, <see cref="MessageStore "/>
|
||||
/// will be reverted to null, and vice versa.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This property may be null in the following cases:
|
||||
@@ -91,7 +91,6 @@ public class ChatClientAgentThread : AgentThread
|
||||
/// to fork the thread with each iteration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">Attempted to set a conversation ID but a <see cref="MessageStore"/> is already set.</exception>
|
||||
public string? ConversationId
|
||||
{
|
||||
get => this._conversationId;
|
||||
|
||||
+2
-2
@@ -75,9 +75,9 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
// The response headers should include the agent thread ID, which can be used to continue the conversation.
|
||||
string? threadId = response.Headers.GetValues("x-ms-thread-id")?.FirstOrDefault();
|
||||
Assert.NotNull(threadId);
|
||||
Assert.NotEmpty(threadId);
|
||||
|
||||
this._outputHelper.WriteLine($"Agent thread ID: {threadId}");
|
||||
Assert.StartsWith("@dafx-joker@", threadId);
|
||||
|
||||
// Wait for up to 30 seconds to see if the agent response is available in the logs
|
||||
await this.WaitForConditionAsync(
|
||||
@@ -289,7 +289,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
startResponse.Headers.TryGetValues("x-ms-thread-id", out IEnumerable<string>? agentIdValues);
|
||||
string? threadId = agentIdValues?.FirstOrDefault();
|
||||
Assert.NotNull(threadId);
|
||||
Assert.NotEmpty(threadId);
|
||||
Assert.StartsWith("@dafx-publisher@", threadId);
|
||||
|
||||
// Wait for the orchestration to report that it's waiting for human approval
|
||||
await this.WaitForConditionAsync(
|
||||
|
||||
@@ -107,6 +107,9 @@ public class TestRunContext : IRunnerContext
|
||||
public ValueTask SendMessageAsync<TMessage>(string senderId, [System.Diagnostics.CodeAnalysis.DisallowNull] TMessage message, CancellationToken cancellationToken = default)
|
||||
=> this.SendMessageAsync(senderId, message, cancellationToken);
|
||||
|
||||
public ValueTask YieldOutputAsync(string sourceId, object output, CancellationToken cancellationToken = default)
|
||||
=> this.AddEventAsync(new WorkflowOutputEvent(output, sourceId), cancellationToken);
|
||||
|
||||
ValueTask<string> ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken) => new(string.Empty);
|
||||
ValueTask<bool> ISuperStepJoinContext.DetachSuperstepAsync(string joinId) => new(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public sealed class WorkflowHostExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WorkflowHostExecutor_WithAutoYieldOutputHandlerResultObjectTrue_YieldsSubworkflowOutput()
|
||||
{
|
||||
// Arrange
|
||||
const string outputData = "test output from subworkflow";
|
||||
|
||||
Func<string, IWorkflowContext, CancellationToken, ValueTask> processFunc = (input, context, cancellationToken) => context.YieldOutputAsync(input, cancellationToken);
|
||||
|
||||
ExecutorBinding subworkflowExecutor = processFunc.BindAsExecutor("SubworkflowExecutor", threadsafe: true);
|
||||
Workflow subworkflow = new WorkflowBuilder(subworkflowExecutor)
|
||||
.WithOutputFrom(subworkflowExecutor)
|
||||
.Build();
|
||||
|
||||
ExecutorBinding workflowHostExecutor = subworkflow.BindAsExecutor(
|
||||
"HostExecutor",
|
||||
new ExecutorOptions
|
||||
{
|
||||
AutoSendMessageHandlerResultObject = false,
|
||||
AutoYieldOutputHandlerResultObject = true
|
||||
});
|
||||
|
||||
Func<string, string, ValueTask<Executor>> createOrchestrator = (id, _) => new(new OrchestratorExecutor(id));
|
||||
ExecutorBinding orchestrator = createOrchestrator.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(orchestrator)
|
||||
.AddEdge(orchestrator, workflowHostExecutor)
|
||||
.AddEdge(workflowHostExecutor, orchestrator)
|
||||
.WithOutputFrom(workflowHostExecutor)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
Run workflowRun = await InProcessExecution.RunAsync(workflow, outputData);
|
||||
|
||||
// Assert
|
||||
RunStatus status = await workflowRun.GetStatusAsync();
|
||||
status.Should().Be(RunStatus.Idle);
|
||||
|
||||
List<WorkflowOutputEvent> outputEvents = workflowRun.OutgoingEvents
|
||||
.OfType<WorkflowOutputEvent>()
|
||||
.ToList();
|
||||
|
||||
outputEvents.Should().HaveCount(1, "the workflow should produce exactly one output event");
|
||||
outputEvents[0].As<string>().Should().Be(outputData, "the output should match the input data");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowHostExecutor_WithAutoSendMessageHandlerResultObjectTrue_SendsMessageNotYield()
|
||||
{
|
||||
// Arrange
|
||||
const string outputData = "test output from subworkflow";
|
||||
|
||||
Func<string, IWorkflowContext, CancellationToken, ValueTask> processFunc = (input, context, cancellationToken) => context.YieldOutputAsync(input, cancellationToken);
|
||||
|
||||
ExecutorBinding subworkflowExecutor = processFunc.BindAsExecutor("SubworkflowExecutor", threadsafe: true);
|
||||
Workflow subworkflow = new WorkflowBuilder(subworkflowExecutor)
|
||||
.WithOutputFrom(subworkflowExecutor)
|
||||
.Build();
|
||||
|
||||
ExecutorBinding workflowHostExecutor = subworkflow.BindAsExecutor(
|
||||
"HostExecutor",
|
||||
new ExecutorOptions
|
||||
{
|
||||
AutoSendMessageHandlerResultObject = true,
|
||||
AutoYieldOutputHandlerResultObject = false
|
||||
});
|
||||
|
||||
Func<string, string, ValueTask<Executor>> createOrchestrator = (id, _) => new(new OrchestratorExecutor(id));
|
||||
ExecutorBinding orchestrator = createOrchestrator.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(orchestrator)
|
||||
.AddEdge(orchestrator, workflowHostExecutor)
|
||||
.AddEdge(workflowHostExecutor, orchestrator)
|
||||
.WithOutputFrom(orchestrator)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
Run workflowRun = await InProcessExecution.RunAsync(workflow, outputData);
|
||||
|
||||
// Assert
|
||||
RunStatus status = await workflowRun.GetStatusAsync();
|
||||
status.Should().Be(RunStatus.Idle);
|
||||
|
||||
List<WorkflowOutputEvent> outputEvents = workflowRun.OutgoingEvents
|
||||
.OfType<WorkflowOutputEvent>()
|
||||
.ToList();
|
||||
|
||||
// With AutoSendMessageHandlerResultObject, the output is sent as a message back to orchestrator, which yields it
|
||||
outputEvents.Should().HaveCount(1, "the workflow should produce exactly one output event");
|
||||
outputEvents[0].As<string>().Should().Be(outputData, "the output should match the input data");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowHostExecutor_WithBothOptionsFalse_DoesNotPropagate()
|
||||
{
|
||||
// Arrange
|
||||
const string outputData = "test output from subworkflow";
|
||||
|
||||
Func<string, IWorkflowContext, CancellationToken, ValueTask> processFunc = (input, context, cancellationToken) => context.YieldOutputAsync(input, cancellationToken);
|
||||
|
||||
ExecutorBinding subworkflowExecutor = processFunc.BindAsExecutor("SubworkflowExecutor", threadsafe: true);
|
||||
Workflow subworkflow = new WorkflowBuilder(subworkflowExecutor)
|
||||
.WithOutputFrom(subworkflowExecutor)
|
||||
.Build();
|
||||
|
||||
ExecutorBinding workflowHostExecutor = subworkflow.BindAsExecutor(
|
||||
"HostExecutor",
|
||||
new ExecutorOptions
|
||||
{
|
||||
AutoSendMessageHandlerResultObject = false,
|
||||
AutoYieldOutputHandlerResultObject = false
|
||||
});
|
||||
|
||||
Func<string, string, ValueTask<Executor>> createOrchestrator = (id, _) => new(new OrchestratorExecutor(id));
|
||||
ExecutorBinding orchestrator = createOrchestrator.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(orchestrator)
|
||||
.AddEdge(orchestrator, workflowHostExecutor)
|
||||
.AddEdge(workflowHostExecutor, orchestrator)
|
||||
.WithOutputFrom(orchestrator)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
Run workflowRun = await InProcessExecution.RunAsync(workflow, outputData);
|
||||
|
||||
// Assert
|
||||
RunStatus status = await workflowRun.GetStatusAsync();
|
||||
status.Should().Be(RunStatus.Idle);
|
||||
|
||||
List<WorkflowOutputEvent> outputEvents = workflowRun.OutgoingEvents
|
||||
.OfType<WorkflowOutputEvent>()
|
||||
.ToList();
|
||||
|
||||
// When both options are false, the subworkflow output is not propagated
|
||||
outputEvents.Should().BeEmpty("no output should be yielded when both options are false");
|
||||
}
|
||||
|
||||
private sealed class OrchestratorExecutor : StatefulExecutor<OrchestratorExecutor.State>
|
||||
{
|
||||
internal sealed class State
|
||||
{
|
||||
public bool ReceivedInput { get; set; }
|
||||
public string? Result { get; set; }
|
||||
}
|
||||
|
||||
public OrchestratorExecutor(string id)
|
||||
: base(id, () => new State(), declareCrossRunShareable: false)
|
||||
{
|
||||
}
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
return routeBuilder
|
||||
.AddHandler<string>(this.HandleInputAsync);
|
||||
}
|
||||
|
||||
private async ValueTask HandleInputAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
await this.InvokeWithStateAsync(ProcessInputAsync, context, cancellationToken: cancellationToken);
|
||||
|
||||
async ValueTask<State?> ProcessInputAsync(State state, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!state.ReceivedInput)
|
||||
{
|
||||
state.ReceivedInput = true;
|
||||
await context.SendMessageAsync(input, cancellationToken: cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
state.Result = input;
|
||||
await context.YieldOutputAsync(input, cancellationToken);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0b251117] - 2025-11-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-ag-ui**: Fix ag-ui state handling issues ([#2289](https://github.com/microsoft/agent-framework/pull/2289))
|
||||
|
||||
## [1.0.0b251114] - 2025-11-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -86,7 +86,6 @@ class AgentFrameworkEventBridge:
|
||||
self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message
|
||||
self.tool_results: list[dict[str, Any]] = [] # Track tool results
|
||||
self.tool_calls_ended: set[str] = set() # Track which tool calls have had ToolCallEndEvent emitted
|
||||
self.accumulated_text_content: str = "" # Track accumulated text for final MessagesSnapshotEvent
|
||||
|
||||
async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]:
|
||||
"""
|
||||
@@ -100,29 +99,18 @@ class AgentFrameworkEventBridge:
|
||||
"""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
logger.info(f"Processing AgentRunUpdate with {len(update.contents)} content items")
|
||||
for idx, content in enumerate(update.contents):
|
||||
logger.info(f" Content {idx}: type={type(content).__name__}")
|
||||
for content in update.contents:
|
||||
if isinstance(content, TextContent):
|
||||
logger.info(
|
||||
f" TextContent found: text_length={len(content.text)}, text_preview='{content.text[:100]}'"
|
||||
)
|
||||
logger.info(
|
||||
f" Flags: skip_text_content={self.skip_text_content}, should_stop_after_confirm={self.should_stop_after_confirm}"
|
||||
)
|
||||
|
||||
# Skip text content if using structured outputs (it's just the JSON)
|
||||
if self.skip_text_content:
|
||||
logger.info(" SKIPPING TextContent: skip_text_content is True")
|
||||
continue
|
||||
|
||||
# Skip text content if we're about to emit confirm_changes
|
||||
# The summary should only appear after user confirms
|
||||
if self.should_stop_after_confirm:
|
||||
logger.info(" SKIPPING TextContent: waiting for confirm_changes response")
|
||||
logger.debug("Skipping text content - waiting for confirm_changes response")
|
||||
# Save the summary text to show after confirmation
|
||||
self.suppressed_summary += content.text
|
||||
logger.info(f" Suppressed summary now has {len(self.suppressed_summary)} chars")
|
||||
continue
|
||||
|
||||
if not self.current_message_id:
|
||||
@@ -131,16 +119,14 @@ class AgentFrameworkEventBridge:
|
||||
message_id=self.current_message_id,
|
||||
role="assistant",
|
||||
)
|
||||
logger.info(f" EMITTING TextMessageStartEvent with message_id={self.current_message_id}")
|
||||
logger.debug(f"Emitting TextMessageStartEvent with message_id={self.current_message_id}")
|
||||
events.append(start_event)
|
||||
|
||||
event = TextMessageContentEvent(
|
||||
message_id=self.current_message_id,
|
||||
delta=content.text,
|
||||
)
|
||||
# Accumulate text content for final MessagesSnapshotEvent
|
||||
self.accumulated_text_content += content.text
|
||||
logger.info(f" EMITTING TextMessageContentEvent with delta: '{content.text}'")
|
||||
logger.debug(f"Emitting TextMessageContentEvent with delta: {content.text}")
|
||||
events.append(event)
|
||||
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
@@ -441,24 +427,7 @@ class AgentFrameworkEventBridge:
|
||||
|
||||
# Emit MessagesSnapshotEvent with the complete conversation including tool calls and results
|
||||
# This is required for CopilotKit's useCopilotAction to detect tool result
|
||||
# HOWEVER: Skip this for predictive tools when require_confirmation=False, because
|
||||
# the agent will generate a follow-up text message and we'll emit a complete snapshot at the end.
|
||||
# Emitting here would create an incomplete snapshot that gets replaced, causing UI flicker.
|
||||
should_emit_snapshot = self.pending_tool_calls and self.tool_results
|
||||
|
||||
# Check if this is a predictive tool that will have a follow-up message
|
||||
is_predictive_without_confirmation = False
|
||||
if should_emit_snapshot and self.current_tool_call_name and self.predict_state_config:
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] == self.current_tool_call_name and not self.require_confirmation:
|
||||
is_predictive_without_confirmation = True
|
||||
logger.info(
|
||||
f"Skipping intermediate MessagesSnapshotEvent for predictive tool '{self.current_tool_call_name}' "
|
||||
"- will emit complete snapshot after follow-up message"
|
||||
)
|
||||
break
|
||||
|
||||
if should_emit_snapshot and not is_predictive_without_confirmation:
|
||||
if self.pending_tool_calls and self.tool_results:
|
||||
# Import message adapter
|
||||
from ._message_adapters import agent_framework_messages_to_agui
|
||||
|
||||
|
||||
@@ -283,62 +283,8 @@ def extract_text_from_contents(contents: list[Any]) -> str:
|
||||
return "".join(text_parts)
|
||||
|
||||
|
||||
def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Normalize AG-UI messages for MessagesSnapshotEvent.
|
||||
|
||||
Converts AG-UI input format (with 'input_text' type) to snapshot format (with 'text' type).
|
||||
|
||||
Args:
|
||||
messages: List of AG-UI messages in input format
|
||||
|
||||
Returns:
|
||||
List of normalized messages suitable for MessagesSnapshotEvent
|
||||
"""
|
||||
from ._utils import generate_event_id
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
normalized_msg = msg.copy()
|
||||
|
||||
# Ensure ID exists
|
||||
if "id" not in normalized_msg:
|
||||
normalized_msg["id"] = generate_event_id()
|
||||
|
||||
# Normalize content field
|
||||
content = normalized_msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Convert content array format to simple string
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
# Convert 'input_text' to 'text' type
|
||||
if item.get("type") == "input_text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
else:
|
||||
# Other types - just extract text field if present
|
||||
text_parts.append(item.get("text", ""))
|
||||
normalized_msg["content"] = "".join(text_parts)
|
||||
elif content is None:
|
||||
normalized_msg["content"] = ""
|
||||
|
||||
# Normalize tool_call_id to toolCallId for tool messages
|
||||
if normalized_msg.get("role") == "tool":
|
||||
if "tool_call_id" in normalized_msg:
|
||||
normalized_msg["toolCallId"] = normalized_msg["tool_call_id"]
|
||||
del normalized_msg["tool_call_id"]
|
||||
elif "toolCallId" not in normalized_msg:
|
||||
normalized_msg["toolCallId"] = ""
|
||||
|
||||
result.append(normalized_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"agui_messages_to_agent_framework",
|
||||
"agent_framework_messages_to_agui",
|
||||
"agui_messages_to_snapshot_format",
|
||||
"extract_text_from_contents",
|
||||
]
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
MessagesSnapshotEvent,
|
||||
RunErrorEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
@@ -589,37 +588,32 @@ class DefaultOrchestrator(Orchestrator):
|
||||
# We should NOT add to thread.on_new_messages() as that would cause duplication.
|
||||
# Instead, we pass messages directly to the agent via messages_to_run.
|
||||
|
||||
# Inject current state as system message context if we have state and this is a new user turn
|
||||
# Inject current state as system message context if we have state
|
||||
messages_to_run: list[Any] = []
|
||||
|
||||
# Check if the last message is from the user (new turn) vs assistant/tool (mid-execution)
|
||||
is_new_user_turn = False
|
||||
if provider_messages:
|
||||
last_msg = provider_messages[-1]
|
||||
is_new_user_turn = last_msg.role.value == "user"
|
||||
|
||||
# Check if conversation has tool calls (indicates mid-execution)
|
||||
conversation_has_tool_calls = False
|
||||
logger.debug(f"Checking {len(provider_messages)} provider messages for tool calls")
|
||||
for i, msg in enumerate(provider_messages):
|
||||
logger.debug(
|
||||
f" Message {i}: role={msg.role.value}, contents={len(msg.contents) if hasattr(msg, 'contents') and msg.contents else 0}"
|
||||
)
|
||||
for msg in provider_messages:
|
||||
if msg.role.value == "assistant" and hasattr(msg, "contents") and msg.contents:
|
||||
if any(isinstance(content, FunctionCallContent) for content in msg.contents):
|
||||
conversation_has_tool_calls = True
|
||||
break
|
||||
|
||||
# Only inject state context on new user turns AND when conversation doesn't have tool calls
|
||||
# (tool calls indicate we're mid-execution, so state context was already injected)
|
||||
if current_state and context.config.state_schema and is_new_user_turn and not conversation_has_tool_calls:
|
||||
if current_state and context.config.state_schema and not conversation_has_tool_calls:
|
||||
state_json = json.dumps(current_state, indent=2)
|
||||
state_context_msg = ChatMessage(
|
||||
role="system",
|
||||
contents=[
|
||||
TextContent(
|
||||
text=f"""Current state of the application:
|
||||
{state_json}
|
||||
{state_json}
|
||||
|
||||
When modifying state, you MUST include ALL existing data plus your changes.
|
||||
For example, if adding one new item to a list, include ALL existing items PLUS the one new item.
|
||||
Never replace existing data - always preserve and append or merge."""
|
||||
When modifying state, you MUST include ALL existing data plus your changes.
|
||||
For example, if adding a new ingredient, include all existing ingredients PLUS the new one.
|
||||
Never replace existing data - always append or merge."""
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -720,19 +714,12 @@ class DefaultOrchestrator(Orchestrator):
|
||||
|
||||
# Collect all updates to get the final structured output
|
||||
all_updates: list[Any] = []
|
||||
update_count = 0
|
||||
async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=tools_param):
|
||||
update_count += 1
|
||||
logger.info(f"[STREAM] Received update #{update_count} from agent")
|
||||
all_updates.append(update)
|
||||
events = await event_bridge.from_agent_run_update(update)
|
||||
logger.info(f"[STREAM] Update #{update_count} produced {len(events)} events")
|
||||
for event in events:
|
||||
logger.info(f"[STREAM] Yielding event: {type(event).__name__}")
|
||||
yield event
|
||||
|
||||
logger.info(f"[STREAM] Agent stream completed. Total updates: {update_count}")
|
||||
|
||||
# After agent completes, check if we should stop (waiting for user to confirm changes)
|
||||
if event_bridge.should_stop_after_confirm:
|
||||
logger.info("Stopping run after confirm_changes - waiting for user response")
|
||||
@@ -806,56 +793,9 @@ class DefaultOrchestrator(Orchestrator):
|
||||
yield TextMessageEndEvent(message_id=message_id)
|
||||
logger.info(f"Emitted conversational message: {response_dict['message'][:100]}...")
|
||||
|
||||
logger.info(f"[FINALIZE] Checking for unclosed message. current_message_id={event_bridge.current_message_id}")
|
||||
if event_bridge.current_message_id:
|
||||
logger.info(f"[FINALIZE] Emitting TextMessageEndEvent for message_id={event_bridge.current_message_id}")
|
||||
yield event_bridge.create_message_end_event(event_bridge.current_message_id)
|
||||
|
||||
# Emit MessagesSnapshotEvent to persist the final assistant text message
|
||||
from ._message_adapters import agui_messages_to_snapshot_format
|
||||
|
||||
# Build the final assistant message with accumulated text content
|
||||
assistant_text_message = {
|
||||
"id": event_bridge.current_message_id,
|
||||
"role": "assistant",
|
||||
"content": event_bridge.accumulated_text_content,
|
||||
}
|
||||
|
||||
# Convert input messages to snapshot format (normalize content structure)
|
||||
# event_bridge.input_messages are already in AG-UI format, just need normalization
|
||||
converted_input_messages = agui_messages_to_snapshot_format(event_bridge.input_messages)
|
||||
|
||||
# Build complete messages array
|
||||
# Include: input messages + any pending tool calls/results + final text message
|
||||
all_messages = converted_input_messages.copy()
|
||||
|
||||
# Add assistant message with tool calls if any
|
||||
if event_bridge.pending_tool_calls:
|
||||
tool_call_message = {
|
||||
"id": generate_event_id(),
|
||||
"role": "assistant",
|
||||
"tool_calls": event_bridge.pending_tool_calls.copy(),
|
||||
}
|
||||
all_messages.append(tool_call_message)
|
||||
|
||||
# Add tool results if any
|
||||
all_messages.extend(event_bridge.tool_results.copy())
|
||||
|
||||
# Add final text message
|
||||
all_messages.append(assistant_text_message)
|
||||
|
||||
messages_snapshot = MessagesSnapshotEvent(
|
||||
messages=all_messages, # type: ignore[arg-type]
|
||||
)
|
||||
logger.info(
|
||||
f"[FINALIZE] Emitting MessagesSnapshotEvent with {len(all_messages)} messages "
|
||||
f"(text content length: {len(event_bridge.accumulated_text_content)})"
|
||||
)
|
||||
yield messages_snapshot
|
||||
else:
|
||||
logger.info("[FINALIZE] No current_message_id - skipping TextMessageEndEvent")
|
||||
|
||||
logger.info("[FINALIZE] Emitting RUN_FINISHED event")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
logger.info(f"Completed agent run for thread_id={context.thread_id}, run_id={context.run_id}")
|
||||
|
||||
|
||||
@@ -130,5 +130,4 @@ def recipe_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
|
||||
"recipe": {"tool": "update_recipe", "tool_argument": "recipe"},
|
||||
},
|
||||
confirmation_strategy=RecipeConfirmationStrategy(),
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251117"
|
||||
version = "1.0.0b251114"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -18,40 +18,20 @@ Follow the common setup steps in `../README.md` to install tooling, configure Az
|
||||
|
||||
Send a prompt to the Joker agent:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -i -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a short joke about cloud computing."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post -Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-Body "Tell me a short joke about cloud computing."
|
||||
```
|
||||
|
||||
The agent responds with a JSON payload that includes the generated joke.
|
||||
|
||||
> [!TIP]
|
||||
> To return immediately with an HTTP 202 response instead of waiting for the agent output, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body. The default behavior waits for the response.
|
||||
> **Note:** To return immediately with an HTTP 202 response instead of waiting for the agent output, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body. The default behavior waits for the response.
|
||||
|
||||
## Expected Output
|
||||
|
||||
The default plain-text response looks like the following:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
x-ms-thread-id: 4f205157170244bfbd80209df383757e
|
||||
|
||||
Why did the cloud break up with the server?
|
||||
|
||||
Because it found someone more "uplifting"!
|
||||
```
|
||||
|
||||
When you specify the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body, the Functions host responds with an HTTP 202 and queues the request to run in the background. A typical response body looks like the following:
|
||||
When you send a POST request with plain-text input, the Functions host responds with an HTTP 202 and queues the request for the durable agent entity. A typical response body looks like the following:
|
||||
Expected HTTP 202 payload:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user