microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
kavin/agents-sdk-interop

Branches

Tags

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

Clone

HTTPS

Download ZIP

core/samples/A2ABot/Agent.cs

171lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.ClientModel;
5using System.Collections.Concurrent;
6using System.ComponentModel;
7using A2ABot.A2A;
8using Azure.AI.OpenAI;
9using Microsoft.Agents.AI;
10using Microsoft.Extensions.AI;
11using AgentCard = A2A.AgentCard;
12
13namespace A2ABot;
14
15// LLM with a single `handoff_to_peer` tool. The agent framework owns chat
16// history via AgentThread; we cache one thread per Teams conversation and
17// pre-seed it via A2AServer when a peer hands off a user.
18internal sealed class Agent
19{
20 private static readonly AsyncLocal<TurnIdentity?> CurrentTurn = new();
21
22 private readonly Config _config;
23 private readonly A2AClient _a2aClient;
24 private readonly ILogger<Agent> _logger;
25 private readonly IChatClient _chatClient;
26 private readonly ConcurrentDictionary<string, AgentThread> _threads = new();
27 private readonly SemaphoreSlim _initLock = new(1, 1);
28
29 private ChatClientAgent? _agent;
30
31 public Agent(Config config, A2AClient a2aClient, IConfiguration configuration, ILogger<Agent> logger)
32 {
33 _config = config;
34 _a2aClient = a2aClient;
35 _logger = logger;
36
37 string endpoint = Require(configuration, "AzureOpenAI:Endpoint");
38 string apiKey = Require(configuration, "AzureOpenAI:ApiKey");
39 string deployment = Require(configuration, "AzureOpenAI:Deployment");
40
41 if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? endpointUri))
42 throw new InvalidOperationException($"AzureOpenAI:Endpoint is not a valid absolute URI: '{endpoint}'.");
43
44 AzureOpenAIClient azure = new(endpointUri, new ApiKeyCredential(apiKey));
45 _chatClient = azure.GetChatClient(deployment).AsIChatClient();
46 }
47
48 private static string Require(IConfiguration cfg, string key)
49 {
50 string? value = cfg[key];
51 if (string.IsNullOrWhiteSpace(value))
52 throw new InvalidOperationException($"{key} is required (set it in appsettings.json or environment).");
53 return value;
54 }
55
56 public async Task<string> RunAsync(
57 string convId,
58 TurnIdentity identity,
59 string userText,
60 CancellationToken ct)
61 {
62 ChatClientAgent agent = await EnsureAgentAsync(ct);
63 AgentThread thread = _threads.GetOrAdd(convId, _ => agent.GetNewThread());
64
65 CurrentTurn.Value = identity;
66
67 AgentRunResponse response = await agent.RunAsync(userText, thread, cancellationToken: ct);
68 return response.Text ?? string.Empty;
69 }
70
71 // Generate the proactive opening message when a peer hands off a user.
72 // Runs the LLM with the handoff context as the user turn so the model
73 // both greets the user AND answers the question that came in the
74 // summary. The resulting turn is left in the thread, so subsequent
75 // user replies continue the conversation naturally.
76 public async Task<string> GreetWithHandoffAsync(
77 string convId, string fromBot, string userName, string summary, CancellationToken ct)
78 {
79 ChatClientAgent agent = await EnsureAgentAsync(ct);
80 AgentThread thread = _threads.GetOrAdd(convId, _ => agent.GetNewThread());
81
82 string contextPrompt =
83 $"[handoff context from {fromBot}] The user {userName} was just handed off to you. " +
84 $"They asked: \"{summary}\". " +
85 $"Greet them warmly, acknowledge that {fromBot} connected you, and answer their question directly.";
86
87 AgentRunResponse response = await agent.RunAsync(contextPrompt, thread, cancellationToken: ct);
88 return response.Text ?? string.Empty;
89 }
90
91 private async Task<ChatClientAgent> EnsureAgentAsync(CancellationToken ct)
92 {
93 if (_agent is not null) return _agent;
94
95 await _initLock.WaitAsync(ct);
96 try
97 {
98 if (_agent is not null) return _agent;
99
100 string peerDescription = await TryFetchPeerDescriptionAsync(ct);
101
102 AIFunction handoffTool = AIFunctionFactory.Create(HandoffToPeerAsync, new AIFunctionFactoryOptions
103 {
104 Name = "handoff_to_peer",
105 Description = $"Hand off the current user to {_config.PeerName} when {_config.PeerName}'s expertise is a better fit. Pass a concise summary of the discussion so {_config.PeerName} can pick up cold. {_config.PeerName} will then message the user directly.",
106 });
107
108 string instructions = $"""
109 You are {_config.Name}, a Teams bot. Your specialty: {_config.Description}
110
111 You have one peer:
112 - {_config.PeerName}: {peerDescription}
113
114 Guidelines:
115 - If the user's question fits {_config.PeerName}'s specialty better than your own, call handoff_to_peer with a clear summary. Then briefly tell the user you're handing them over.
116 - Otherwise, answer directly.
117 - If you see a "[handoff context from X]" note, the previous bot has already connected the user with you and described their question — greet the user warmly, briefly mention X sent them, and **answer the question directly** in the same message. Don't just ask "how can I help?" — the question is already in the context.
118 - Keep replies short and conversational.
119 """;
120
121 _agent = new ChatClientAgent(
122 _chatClient,
123 instructions: instructions,
124 name: _config.Name,
125 description: _config.Description,
126 tools: [handoffTool]);
127
128 return _agent;
129 }
130 finally
131 {
132 _initLock.Release();
133 }
134 }
135
136 private async Task<string> TryFetchPeerDescriptionAsync(CancellationToken ct)
137 {
138 try
139 {
140 AgentCard card = await _a2aClient.GetPeerCardAsync(ct);
141 return card.Description ?? "(no description)";
142 }
143 catch
144 {
145 return "(peer card not reachable at startup)";
146 }
147 }
148
149 private async Task<string> HandoffToPeerAsync(
150 [Description("Concise summary of what's been discussed and the user's current question, written so the peer can pick up cold.")] string summary,
151 CancellationToken ct)
152 {
153 TurnIdentity? turn = CurrentTurn.Value;
154 if (turn is null)
155 {
156 // Called from a handoff greeting (no identity) — guard against ping-pong.
157 return "handoff_to_peer is unavailable in this context.";
158 }
159
160 _logger.LogInformation(
161 "[{Bot}] handoff_to_peer firing → peer={Peer} user={User} aadId={AadId} tenant={TenantId}",
162 _config.Name, _config.PeerName, turn.UserName, turn.AadObjectId, turn.TenantId);
163
164 await _a2aClient.SendHandoffAsync(
165 new HandoffMessage("handoff", turn.AadObjectId, turn.UserName, summary, _config.Name, turn.TenantId, turn.ServiceUrl),
166 ct);
167
168 _logger.LogInformation("[{Bot}] handoff_to_peer OK", _config.Name);
169 return $"Handoff to {_config.PeerName} confirmed. {_config.PeerName} will message the user directly.";
170 }
171}
172