microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feature/pabot-httpcontext-botid

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/samples/ExtAIBot/Agent.cs

155lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Collections.Concurrent;
5using System.Text;
6using System.Text.Json.Serialization;
7using Microsoft.Extensions.AI;
8using Microsoft.Teams.Apps;
9using Microsoft.Teams.Apps.Schema;
10
11namespace 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.
17sealed 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
151readonly record struct RunResult(
152 string FullText,
153 IList<object> PendingCards,
154 IList<SuggestedAction> FollowUpActions,
155 CitationCollector Citations);
156