microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/samples/ExtAIBot/Agent.cs
155lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | using System.Collections.Concurrent; |
| 5 | using System.Text; |
| 6 | using System.Text.Json.Serialization; |
| 7 | using Microsoft.Extensions.AI; |
| 8 | using Microsoft.Teams.Apps; |
| 9 | using Microsoft.Teams.Apps.Schema; |
| 10 | |
| 11 | namespace ExtAIBot; |
| 12 | |
| 13 | // Holds the IChatClient, per-conversation history, and the MCP tool set. |
| 14 | // RunAsync drives a single turn: it builds per-turn tools (local + MCP wrapped for citations), |
| 15 | // streams the model response, then runs a dedicated structured-output call to |
| 16 | // generate exactly 2 follow-up suggestions. |
| 17 | sealed class Agent |
| 18 | { |
| 19 | private readonly IChatClient _chatClient; |
| 20 | private readonly McpToolSetLifetimeService _mcpTools; |
| 21 | private readonly ILogger<Agent> _logger; |
| 22 | private readonly ConcurrentDictionary<string, List<ChatMessage>> _histories = new(); |
| 23 | // One lock per conversation so concurrent turns on the same conversation serialize |
| 24 | // their history mutations (List<ChatMessage> is not thread-safe). |
| 25 | private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(); |
| 26 | |
| 27 | private const string SystemPrompt = """ |
| 28 | You are a Teams docs assistant that can search Microsoft Learn (Teams, .NET, Microsoft Graph, Azure) |
| 29 | and explain bot concepts (streaming, Adaptive Cards, citations, feedback). |
| 30 | |
| 31 | When you use information from a search tool, cite your sources inline using the "citation" value \ |
| 32 | provided in each result (e.g. [1], [2]). |
| 33 | Do not add a references or sources list at the end of your response — citations are displayed separately in the UI. |
| 34 | """; |
| 35 | |
| 36 | private const string FollowUpsPrompt = """ |
| 37 | Produce 2 specific prompts the user might want to ask next. |
| 38 | |
| 39 | Output format — read carefully: |
| 40 | Return ONLY a JSON object INSTANCE, like this: |
| 41 | {"prompt1": "How do I stream a reply?", "prompt2": "Show me an Adaptive Card example"} |
| 42 | |
| 43 | Each prompt MUST: |
| 44 | - Be phrased in the first person, as the user would type. |
| 45 | - Stay under 8 words. |
| 46 | |
| 47 | Pick based on the conversation: |
| 48 | - If recent turns have substantive content, drill into a concrete topic, API, or |
| 49 | concept that just came up. |
| 50 | - Otherwise (e.g. conversation just started, or the last turn is generic), |
| 51 | suggest prompts that showcase what you can help with based on the MCP tools available. |
| 52 | """; |
| 53 | |
| 54 | private sealed record FollowUps( |
| 55 | [property: JsonPropertyName("prompt1")] string Prompt1, |
| 56 | [property: JsonPropertyName("prompt2")] string Prompt2); |
| 57 | |
| 58 | public Agent(IChatClient chatClient, McpToolSetLifetimeService mcpTools, ILogger<Agent> logger) |
| 59 | { |
| 60 | _chatClient = chatClient; |
| 61 | _mcpTools = mcpTools; |
| 62 | _logger = logger; |
| 63 | } |
| 64 | |
| 65 | public async Task<RunResult> RunAsync( |
| 66 | string conversationId, |
| 67 | string userText, |
| 68 | TeamsStreamingWriter writer, |
| 69 | CancellationToken cancellationToken) |
| 70 | { |
| 71 | List<ChatMessage> history = _histories.GetOrAdd( |
| 72 | conversationId, |
| 73 | _ => [new ChatMessage(ChatRole.System, SystemPrompt)]); |
| 74 | |
| 75 | // Serialize turns within a single conversation so concurrent submits |
| 76 | // (e.g. clarification race) don't interleave history mutations. |
| 77 | SemaphoreSlim gate = _locks.GetOrAdd(conversationId, _ => new SemaphoreSlim(1, 1)); |
| 78 | await gate.WaitAsync(cancellationToken).ConfigureAwait(false); |
| 79 | try |
| 80 | { |
| 81 | List<object> pendingCards = []; |
| 82 | CitationCollector citations = new(_logger); |
| 83 | McpToolSet mcpTools = _mcpTools.Value; |
| 84 | |
| 85 | ChatOptions options = new() |
| 86 | { |
| 87 | Tools = |
| 88 | [ |
| 89 | LocalTools.CreateClarificationCardTool(pendingCards, _logger), |
| 90 | .. mcpTools.GetTools(citations) |
| 91 | ] |
| 92 | }; |
| 93 | |
| 94 | history.Add(new ChatMessage(ChatRole.User, userText)); |
| 95 | await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken); |
| 96 | |
| 97 | StringBuilder fullText = new(); |
| 98 | await foreach (ChatResponseUpdate update in |
| 99 | _chatClient.GetStreamingResponseAsync(history, options, cancellationToken)) |
| 100 | { |
| 101 | if (!string.IsNullOrEmpty(update.Text)) |
| 102 | { |
| 103 | await writer.AppendResponseAsync(update.Text, cancellationToken); |
| 104 | fullText.Append(update.Text); |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | string fullTextStr = fullText.ToString(); |
| 109 | if (fullTextStr.Length > 0) |
| 110 | history.Add(new ChatMessage(ChatRole.Assistant, fullTextStr)); |
| 111 | |
| 112 | List<SuggestedAction> followUpActions = await GenerateFollowUpsAsync(history, cancellationToken); |
| 113 | |
| 114 | return new RunResult(fullTextStr, pendingCards, followUpActions, citations); |
| 115 | } |
| 116 | finally |
| 117 | { |
| 118 | gate.Release(); |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // Runs after the streamed reply is in history. Forces structured JSON output matching |
| 123 | // the FollowUps shape so we always get exactly 2 suggestions to display as chips. |
| 124 | private async Task<List<SuggestedAction>> GenerateFollowUpsAsync( |
| 125 | IReadOnlyList<ChatMessage> history, |
| 126 | CancellationToken cancellationToken) |
| 127 | { |
| 128 | List<ChatMessage> messages = |
| 129 | [ |
| 130 | .. history, |
| 131 | new ChatMessage(ChatRole.System, FollowUpsPrompt) |
| 132 | ]; |
| 133 | |
| 134 | ChatResponse<FollowUps> response = await _chatClient.GetResponseAsync<FollowUps>( |
| 135 | messages, |
| 136 | cancellationToken: cancellationToken); |
| 137 | |
| 138 | if (!response.TryGetResult(out FollowUps? followUps) || followUps is null) |
| 139 | { |
| 140 | _logger.LogWarning("Follow-up generation did not return parseable JSON. Raw response: {Text}", response.Text); |
| 141 | return []; |
| 142 | } |
| 143 | |
| 144 | return [ |
| 145 | new SuggestedAction(ActionType.IMBack, followUps.Prompt1), |
| 146 | new SuggestedAction(ActionType.IMBack, followUps.Prompt2) |
| 147 | ]; |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | readonly record struct RunResult( |
| 152 | string FullText, |
| 153 | IList<object> PendingCards, |
| 154 | IList<SuggestedAction> FollowUpActions, |
| 155 | CitationCollector Citations); |
| 156 | |