Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/core.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<Project Path="samples/CompatProactive/CompatProactive.csproj" Id="60e946ea-ea2f-4994-8b5b-e6aa98723aa4" />
<Project Path="samples/CoreBot/CoreBot.csproj" Id="142a67bc-68e7-4020-83cf-51423e317b53" />
<Project Path="samples/CustomHosting/CustomHosting.csproj" Id="de2e42de-3d8f-4a9d-920f-14c7e39fc3c0" />
<Project Path="samples/ExtAIBot/ExtAIBot.csproj" />
<Project Path="samples/McpServer/McpServer.csproj" />
<Project Path="samples/MeetingsBot/MeetingsBot.csproj" />
<Project Path="samples/MessageExtensionBot/MessageExtensionBot.csproj" />
Expand Down
155 changes: 155 additions & 0 deletions core/samples/ExtAIBot/Agent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Concurrent;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Teams.Apps;
using Microsoft.Teams.Apps.Schema;

namespace ExtAIBot;

// Holds the IChatClient, per-conversation history, and the MCP tool set.
// RunAsync drives a single turn: it builds per-turn tools (local + MCP wrapped for citations),
// streams the model response, then runs a dedicated structured-output call to
// generate exactly 2 follow-up suggestions.
sealed class Agent
{
private readonly IChatClient _chatClient;
private readonly McpToolSetLifetimeService _mcpTools;
private readonly ILogger<Agent> _logger;
private readonly ConcurrentDictionary<string, List<ChatMessage>> _histories = new();
// One lock per conversation so concurrent turns on the same conversation serialize
// their history mutations (List<ChatMessage> is not thread-safe).
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
Comment thread
MehakBindra marked this conversation as resolved.

private const string SystemPrompt = """
You are a Teams docs assistant that can search Microsoft Learn (Teams, .NET, Microsoft Graph, Azure)
and explain bot concepts (streaming, Adaptive Cards, citations, feedback).

When you use information from a search tool, cite your sources inline using the "citation" value \
provided in each result (e.g. [1], [2]).
Do not add a references or sources list at the end of your response — citations are displayed separately in the UI.
""";

private const string FollowUpsPrompt = """
Produce 2 specific prompts the user might want to ask next.

Output format — read carefully:
Return ONLY a JSON object INSTANCE, like this:
{"prompt1": "How do I stream a reply?", "prompt2": "Show me an Adaptive Card example"}

Each prompt MUST:
- Be phrased in the first person, as the user would type.
- Stay under 8 words.

Pick based on the conversation:
- If recent turns have substantive content, drill into a concrete topic, API, or
concept that just came up.
- Otherwise (e.g. conversation just started, or the last turn is generic),
suggest prompts that showcase what you can help with based on the MCP tools available.
""";

private sealed record FollowUps(
[property: JsonPropertyName("prompt1")] string Prompt1,
[property: JsonPropertyName("prompt2")] string Prompt2);

public Agent(IChatClient chatClient, McpToolSetLifetimeService mcpTools, ILogger<Agent> logger)
{
_chatClient = chatClient;
_mcpTools = mcpTools;
_logger = logger;
}

public async Task<RunResult> RunAsync(
string conversationId,
string userText,
TeamsStreamingWriter writer,
CancellationToken cancellationToken)
{
List<ChatMessage> history = _histories.GetOrAdd(
conversationId,
_ => [new ChatMessage(ChatRole.System, SystemPrompt)]);

// Serialize turns within a single conversation so concurrent submits
// (e.g. clarification race) don't interleave history mutations.
SemaphoreSlim gate = _locks.GetOrAdd(conversationId, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<object> pendingCards = [];
CitationCollector citations = new(_logger);
McpToolSet mcpTools = _mcpTools.Value;

ChatOptions options = new()
{
Tools =
[
LocalTools.CreateClarificationCardTool(pendingCards, _logger),
.. mcpTools.GetTools(citations)
]
};

history.Add(new ChatMessage(ChatRole.User, userText));
await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken);

StringBuilder fullText = new();
await foreach (ChatResponseUpdate update in
_chatClient.GetStreamingResponseAsync(history, options, cancellationToken))
{
if (!string.IsNullOrEmpty(update.Text))
{
await writer.AppendResponseAsync(update.Text, cancellationToken);
fullText.Append(update.Text);
}
}

string fullTextStr = fullText.ToString();
if (fullTextStr.Length > 0)
history.Add(new ChatMessage(ChatRole.Assistant, fullTextStr));

List<SuggestedAction> followUpActions = await GenerateFollowUpsAsync(history, cancellationToken);

return new RunResult(fullTextStr, pendingCards, followUpActions, citations);
}
finally
{
gate.Release();
}
}

// Runs after the streamed reply is in history. Forces structured JSON output matching
// the FollowUps shape so we always get exactly 2 suggestions to display as chips.
private async Task<List<SuggestedAction>> GenerateFollowUpsAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken)
{
List<ChatMessage> messages =
[
.. history,
new ChatMessage(ChatRole.System, FollowUpsPrompt)
];

ChatResponse<FollowUps> response = await _chatClient.GetResponseAsync<FollowUps>(
messages,
cancellationToken: cancellationToken);

if (!response.TryGetResult(out FollowUps? followUps) || followUps is null)
{
_logger.LogWarning("Follow-up generation did not return parseable JSON. Raw response: {Text}", response.Text);
return [];
}

return [
new SuggestedAction(ActionType.IMBack, followUps.Prompt1),
new SuggestedAction(ActionType.IMBack, followUps.Prompt2)
];
}
}

readonly record struct RunResult(
string FullText,
IList<object> PendingCards,
IList<SuggestedAction> FollowUpActions,
CitationCollector Citations);
110 changes: 110 additions & 0 deletions core/samples/ExtAIBot/CitationCollector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Teams.Apps.Schema.Entities;

namespace ExtAIBot;

// Parses MCP search results as they are returned by tools and accumulates citation metadata.
// After streaming completes, BuildEntities() returns Teams CitationEntity objects for any
// [N] references that appear in the final response text.
sealed class CitationCollector
{
private readonly ILogger _logger;
private readonly Dictionary<string, CitationEntry> _citations = [];

public CitationCollector(ILogger logger)
{
_logger = logger;
}

public void TryExtract(string result)
{
try
{
using JsonDocument doc = JsonDocument.Parse(result);
if (!TryFindResults(doc.RootElement, out JsonElement results)) return;

foreach (JsonElement item in results.EnumerateArray())
{
string? url = GetString(item, "contentUrl") ?? GetString(item, "link");
if (url is null || _citations.ContainsKey(url)) continue;

string snippet = GetString(item, "content") ?? GetString(item, "description") ?? "";
_citations[url] = new CitationEntry(
Position: _citations.Count + 1,
Url: url,
Title: GetString(item, "title") ?? "",
Snippet: snippet.Length > 160 ? snippet[..160] : snippet);
}
}
catch (JsonException ex)
{
_logger.LogDebug(ex, "Skipped citation extraction because the tool result was not valid JSON.");
}
catch (FormatException ex)
{
_logger.LogDebug(ex, "Skipped citation extraction because the tool result had an unexpected format.");
}
}
Comment thread
MehakBindra marked this conversation as resolved.

public IList<Entity> BuildEntities(string fullText)
{
HashSet<int> used = [];
foreach (Match match in Regex.Matches(fullText, @"\[(\d+)\]"))
{
if (int.TryParse(match.Groups[1].Value, out int position))
used.Add(position);
}

List<CitationClaim> claims = [.. _citations.Values
.Where(e => used.Contains(e.Position))
.Select(e => new CitationClaim
{
Position = e.Position,
Appearance = new CitationAppearance
{
Name = string.IsNullOrEmpty(e.Title)
? $"Source {e.Position}"
: e.Title[..Math.Min(80, e.Title.Length)],
Abstract = string.IsNullOrEmpty(e.Snippet)
? "No description available."
: e.Snippet,
Url = Uri.TryCreate(e.Url, UriKind.Absolute, out Uri? uri) ? uri : null
}.ToDocument()
})];

// TODO : work on Add Citations/feedback/AI label etc in builder
return claims.Count == 0
? [new OMessageEntity { AdditionalType = ["AIGeneratedContent"] }]
: [new CitationEntity { AdditionalType = ["AIGeneratedContent"], Citation = claims }];
}

// MCP InvokeAsync returns a JsonElement of CallToolResult, not the raw server JSON.
// Results may be at root or nested one level deep (e.g. CallToolResult.structuredContent.results).
private static bool TryFindResults(JsonElement element, out JsonElement results)
{
if (element.TryGetProperty("results", out results) && results.ValueKind == JsonValueKind.Array)
return true;

foreach (JsonProperty prop in element.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.Object &&
prop.Value.TryGetProperty("results", out results) &&
results.ValueKind == JsonValueKind.Array)
return true;
}

results = default;
return false;
}

private static string? GetString(JsonElement el, string property) =>
el.TryGetProperty(property, out JsonElement v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
}

sealed record CitationEntry(int Position, string Url, string Title, string Snippet);
21 changes: 21 additions & 0 deletions core/samples/ExtAIBot/ExtAIBot.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Teams.Apps\Microsoft.Teams.Apps.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.2" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.2" />
<PackageReference Include="Microsoft.Teams.Cards" Version="2.0.6" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
</ItemGroup>

</Project>
56 changes: 56 additions & 0 deletions core/samples/ExtAIBot/LocalTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Teams.Cards;

namespace ExtAIBot;

// Provides local AIFunction definitions that the model can call during a turn.
static class LocalTools
{
// Returns a fresh AIFunction each turn; pendingCards is a per-turn accumulator
// captured by closure.

private static readonly JsonSerializerOptions SerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static AIFunction CreateClarificationCardTool(IList<object> pendingCards, ILogger logger) =>
AIFunctionFactory.Create(
([Description("The clarification question to ask the user.")] string question,
[Description("2–4 candidate interpretations the user can pick between.")] string[] options) =>
{
logger.LogInformation("[tool] request_clarification(question={Question}, options=[{Options}])",
question, string.Join(", ", options));
pendingCards.Add(BuildClarificationCard(question, options));
return "Clarification card attached.";
},
"request_clarification",
"Show an Adaptive Card asking the user to clarify their request when needed. " +
"The user picks one option and submits; their choice arrives as the next user turn.");

private static JsonElement BuildClarificationCard(string question, string[] options)
{
AdaptiveCard card = new AdaptiveCard(
new TextBlock(question)
.WithSize(TextSize.Medium)
.WithWeight(TextWeight.Bolder)
.WithWrap(true),
new ChoiceSetInput([.. options.Select(o => new Choice { Title = o, Value = o })])
.WithId("clarificationChoice")
.WithIsRequired(true)
.WithErrorMessage("Please pick one option."))
.WithVersion(Microsoft.Teams.Cards.Version.Version1_6)
.WithActions(
new ExecuteAction()
.WithTitle("Submit")
.WithVerb("clarification")
.WithAssociatedInputs(AssociatedInputs.Auto));

return JsonSerializer.SerializeToElement(card, SerializerOptions);
}
}
Loading
Loading