Files
agent-framework/dotnet/samples/05-end-to-end/M365Agent/AFAgentApplication.cs
Chris 904a5b843e Python / .NET Samples - Restructure and Improve Samples (Feature Branc… (#4092)
* Python: .NET Samples - Restructure and Improve Samples (Feature Branch) (#4091)

* Moved by agent (#4094)

* Fix readme links

* .NET Samples - Create `04-hosting` learning path step (#4098)

* Agent move

* Agent reorderd

* Remove A2A section from README 

Removed A2A section from the Getting Started README.

* Agent fixed links

* Fix broken sample links in durable-agents README (#4101)

* Initial plan

* Fix broken internal links in documentation

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Revert template link changes; keep only durable-agents README fix

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* .NET Samples - Create `03-workflows` learning path step (#4102)

* Fix solution project path

* Python: Fix broken markdown links to repo resources (outside /docs) (#4105)

* Initial plan

* Fix broken markdown links to repo resources

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Update README to rename .NET Workflows Samples section

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* .NET Samples - Create `02-agents` learning path step (#4107)

* .NET: Fix broken relative link in GroupChatToolApproval README (#4108)

* Initial plan

* Fix broken link in GroupChatToolApproval README

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Update labeler configuration for workflow samples

* .NET - Reorder Agents samples to start from Step01 instead of Step04 (#4110)

* Fix solution

* Resolve new sample paths

* Move new AgentSkills and AgentWithMemory_Step04 samples

* Fix link

* Fix readme path

* fix: update stale dotnet/samples/Durable path reference in AGENTS.md

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Moved new sample

* Update solution

* Resolve merge (new sample)

* Sync to new sample - FoundryAgents_Step21_BingCustomSearch

* Updated README

* .NET Samples - Configuration Naming Update (#4149)

* .NET: Restore AzureFunctions index parity with ConsoleApps under DurableAgents samples (#4221)

* Clean-up `05_host_your_agent`

* Config setting consistency

* Refine samples

* AGENTS.md

* Move new samples

* Re-order samples

* Move new project and fixup solution

* Fixup model config

* Fix up new UT project

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-02-26 00:56:10 +00:00

185 lines
8.3 KiB
C#

// 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 sessionElementStart = turnState.GetValue<JsonElement>("conversation.chatHistory");
// Deserialize the conversation history into an AgentSession, or create a new one if none exists.
AgentSession agentSession = sessionElementStart.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null
? await this._agent.DeserializeSessionAsync(sessionElementStart, JsonUtilities.DefaultOptions, cancellationToken)
: await this._agent.CreateSessionAsync(cancellationToken);
ChatMessage chatMessage = HandleUserInput(turnContext);
// Invoke the WeatherForecastAgent to process the message
AgentResponse agentResponse = await this._agent.RunAsync(chatMessage, agentSession, 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(agentResponse, ref attachments);
// Check for Adaptive Card content in the response messages
// and return them appropriately in the response.
var adaptiveCards = agentResponse.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(agentResponse.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 sessionElementEnd = await this._agent.SerializeSessionAsync(agentSession, JsonUtilities.DefaultOptions, cancellationToken);
turnState.SetValue("conversation.chatHistory", sessionElementEnd);
// 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 function approval 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="AgentResponse"/> that may contain the function approval requests.</param>
/// <param name="attachments">The list of <see cref="Attachment"/> to which the adaptive cards will be added.</param>
private static void HandleUserInputRequests(AgentResponse response, ref List<Attachment>? attachments)
{
foreach (FunctionApprovalRequestContent functionApprovalRequest in response.Messages.SelectMany(m => m.Contents).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(),
});
}
}
}