Skip to content

Commit f10b94c

Browse files
authored
Move to Az CLI metadata service from the local cache files (#248)
1 parent b87270e commit f10b94c

File tree

5 files changed

+120
-114
lines changed

5 files changed

+120
-114
lines changed

shell/agents/Microsoft.Azure.Agent/AzureAgent.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,17 @@ 7. DO NOT include the placeholder summary when the commands contains no placehol
3535
private int _turnsLeft;
3636
private readonly string _instructions;
3737
private readonly StringBuilder _buffer;
38+
private readonly HttpClient _httpClient;
3839
private readonly ChatSession _chatSession;
3940
private readonly Dictionary<string, string> _valueStore;
4041

4142
public AzureAgent()
4243
{
4344
_buffer = new StringBuilder();
44-
_chatSession = new ChatSession();
45+
_httpClient = new HttpClient();
46+
Task.Run(() => DataRetriever.WarmUpMetadataService(_httpClient));
47+
48+
_chatSession = new ChatSession(_httpClient);
4549
_valueStore = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
4650
_instructions = string.Format(InstructionPrompt, Environment.OSVersion.VersionString);
4751

@@ -66,7 +70,9 @@ public AzureAgent()
6670

6771
public void Dispose()
6872
{
69-
_chatSession?.Dispose();
73+
ArgPlaceholder?.DataRetriever?.Dispose();
74+
_chatSession.Dispose();
75+
_httpClient.Dispose();
7076
}
7177

7278
public void Initialize(AgentConfig config)
@@ -126,7 +132,7 @@ public async Task<bool> ChatAsync(string input, IShell shell)
126132
string answer = data is null ? copilotResponse.Text : GenerateAnswer(data);
127133
if (data?.PlaceholderSet is not null)
128134
{
129-
ArgPlaceholder = new ArgumentPlaceholder(input, data);
135+
ArgPlaceholder = new ArgumentPlaceholder(input, data, _httpClient);
130136
}
131137

132138
host.RenderFullResponse(answer);

shell/agents/Microsoft.Azure.Agent/ChatSession.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ internal class ChatSession : IDisposable
2323
private readonly HttpClient _httpClient;
2424
private readonly Dictionary<string, object> _flights;
2525

26-
internal ChatSession()
26+
internal ChatSession(HttpClient httpClient)
2727
{
2828
_dl_secret = Environment.GetEnvironmentVariable("DL_SECRET");
29-
_httpClient = new HttpClient();
29+
_httpClient = httpClient;
3030

3131
// Keys and values for flights are from the portal request.
3232
_flights = new Dictionary<string, object>()
@@ -199,14 +199,14 @@ private HttpRequestMessage PrepareForChat(string input)
199199
text = input,
200200
attachments = new object[] {
201201
new {
202-
contentType = "application/json",
202+
contentType = Utils.JsonContentType,
203203
name = "azurecopilot/clienthandlerdefinitions",
204204
content = new {
205205
clientHandlers = Array.Empty<object>()
206206
}
207207
},
208208
new {
209-
contentType = "application/json",
209+
contentType = Utils.JsonContentType,
210210
name = "azurecopilot/viewcontext",
211211
content = new {
212212
viewContext = new {
@@ -217,7 +217,7 @@ private HttpRequestMessage PrepareForChat(string input)
217217
}
218218
},
219219
new {
220-
contentType = "application/json",
220+
contentType = Utils.JsonContentType,
221221
name = "azurecopilot/flights",
222222
content = new {
223223
flights = _flights
@@ -227,7 +227,7 @@ private HttpRequestMessage PrepareForChat(string input)
227227
};
228228

229229
var json = JsonSerializer.Serialize(requestData, Utils.JsonOptions);
230-
var content = new StringContent(json, Encoding.UTF8, "application/json");
230+
var content = new StringContent(json, Encoding.UTF8, Utils.JsonContentType);
231231
var request = new HttpRequestMessage(HttpMethod.Post, _conversationUrl) { Content = content };
232232

233233
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
@@ -305,7 +305,6 @@ internal async Task<CopilotResponse> GetChatResponseAsync(string input, IStatusC
305305

306306
public void Dispose()
307307
{
308-
_httpClient.Dispose();
309308
_copilotReceiver?.Dispose();
310309
}
311310
}

shell/agents/Microsoft.Azure.Agent/DataRetriever.cs

Lines changed: 70 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Concurrent;
22
using System.ComponentModel;
33
using System.Diagnostics;
4+
using System.Text;
45
using System.Text.Json;
56
using System.Text.RegularExpressions;
67
using AIShell.Abstraction;
@@ -9,11 +10,14 @@ namespace Microsoft.Azure.Agent;
910

1011
internal class DataRetriever : IDisposable
1112
{
13+
private const string MetadataQueryTemplate = "{{\"command\":\"{0}\"}}";
14+
private const string MetadataEndpoint = "https://cli-validation-tool-meta-qry.azurewebsites.net/api/command_metadata";
15+
1216
private static readonly Dictionary<string, NamingRule> s_azNamingRules;
13-
private static readonly ConcurrentDictionary<string, Command> s_azStaticDataCache;
17+
private static readonly ConcurrentDictionary<string, AzCLICommand> s_azStaticDataCache;
1418

15-
private readonly string _staticDataRoot;
1619
private readonly Task _rootTask;
20+
private readonly HttpClient _httpClient;
1721
private readonly SemaphoreSlim _semaphore;
1822
private readonly List<ArgumentPair> _placeholders;
1923
private readonly Dictionary<string, ArgumentPair> _placeholderMap;
@@ -302,11 +306,11 @@ static DataRetriever()
302306
s_azStaticDataCache = new(StringComparer.OrdinalIgnoreCase);
303307
}
304308

305-
internal DataRetriever(ResponseData data)
309+
internal DataRetriever(ResponseData data, HttpClient httpClient)
306310
{
307311
_stop = false;
312+
_httpClient = httpClient;
308313
_semaphore = new SemaphoreSlim(3, 3);
309-
_staticDataRoot = @"E:\yard\tmp\az-cli-out\az";
310314
_placeholders = new(capacity: data.PlaceholderSet.Count);
311315
_placeholderMap = new(capacity: data.PlaceholderSet.Count);
312316

@@ -453,31 +457,23 @@ private ArgumentInfo CreateArgInfo(ArgumentPair pair)
453457
private List<string> GetArgValues(ArgumentPair pair)
454458
{
455459
// First, try to get static argument values if they exist.
460+
bool hasCompleter = true;
456461
string command = pair.Command;
457-
if (!s_azStaticDataCache.TryGetValue(command, out Command commandData))
462+
463+
AzCLICommand commandData = s_azStaticDataCache.GetOrAdd(command, QueryForMetadata);
464+
AzCLIParameter param = commandData?.FindParameter(pair.Parameter);
465+
466+
if (param is not null)
458467
{
459-
string[] cmdElements = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
460-
string dirPath = _staticDataRoot;
461-
for (int i = 1; i < cmdElements.Length - 1; i++)
468+
if (param.Choices?.Count > 0)
462469
{
463-
dirPath = Path.Combine(dirPath, cmdElements[i]);
470+
return param.Choices;
464471
}
465472

466-
string filePath = Path.Combine(dirPath, cmdElements[^1] + ".json");
467-
commandData = File.Exists(filePath)
468-
? JsonSerializer.Deserialize<Command>(File.OpenRead(filePath))
469-
: null;
470-
s_azStaticDataCache.TryAdd(command, commandData);
471-
}
472-
473-
Option option = commandData?.FindOption(pair.Parameter);
474-
List<string> staticValues = option?.Arguments;
475-
if (staticValues?.Count > 0)
476-
{
477-
return staticValues;
473+
hasCompleter = param.HasCompleter;
478474
}
479475

480-
if (_stop) { return null; }
476+
if (_stop || !hasCompleter) { return null; }
481477

482478
// Then, try to get dynamic argument values using AzCLI tab completion.
483479
string commandLine = $"{pair.Command} {pair.Parameter} ";
@@ -551,6 +547,42 @@ private List<string> GetArgValues(ArgumentPair pair)
551547
}
552548
}
553549

550+
private AzCLICommand QueryForMetadata(string azCommand)
551+
{
552+
AzCLICommand command = null;
553+
var reqBody = new StringContent(string.Format(MetadataQueryTemplate, azCommand), Encoding.UTF8, Utils.JsonContentType);
554+
var request = new HttpRequestMessage(HttpMethod.Get, MetadataEndpoint) { Content = reqBody };
555+
556+
try
557+
{
558+
using var cts = new CancellationTokenSource(1200);
559+
var response = _httpClient.Send(request, HttpCompletionOption.ResponseHeadersRead, cts.Token);
560+
561+
if (response.IsSuccessStatusCode)
562+
{
563+
using Stream stream = response.Content.ReadAsStream(cts.Token);
564+
using JsonDocument document = JsonDocument.Parse(stream);
565+
566+
JsonElement root = document.RootElement;
567+
if (root.TryGetProperty("data", out JsonElement data) &&
568+
data.TryGetProperty("metadata", out JsonElement metadata))
569+
{
570+
command = metadata.Deserialize<AzCLICommand>(Utils.JsonOptions);
571+
}
572+
}
573+
else
574+
{
575+
// TODO: telemetry.
576+
}
577+
}
578+
catch (Exception)
579+
{
580+
// TODO: telemetry.
581+
}
582+
583+
return command;
584+
}
585+
554586
internal (string command, string parameter) GetMappedCommand(string placeholderName)
555587
{
556588
if (_placeholderMap.TryGetValue(placeholderName, out ArgumentPair pair))
@@ -585,6 +617,22 @@ public void Dispose()
585617
_rootTask.Wait();
586618
_semaphore.Dispose();
587619
}
620+
621+
internal static void WarmUpMetadataService(HttpClient httpClient)
622+
{
623+
// Send a request to the AzCLI metadata service to warm up the service (code start is slow).
624+
// We query for the command 'az sql server list' which only has 2 parameters,
625+
// so it should cause minimum processing on the server side.
626+
HttpRequestMessage request = new(HttpMethod.Get, MetadataEndpoint)
627+
{
628+
Content = new StringContent(
629+
"{\"command\":\"az sql server list\"}",
630+
Encoding.UTF8,
631+
Utils.JsonContentType)
632+
};
633+
634+
_ = httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
635+
}
588636
}
589637

590638
internal class ArgumentPair
@@ -703,83 +751,3 @@ internal bool TryMatchName(string name, out string prodName, out string envName)
703751
return false;
704752
}
705753
}
706-
707-
public class Option
708-
{
709-
public string Name { get; }
710-
public string[] Alias { get; }
711-
public string[] Short { get; }
712-
public string Attribute { get; }
713-
public string Description { get; set; }
714-
public List<string> Arguments { get; set; }
715-
716-
public Option(string name, string description, string[] alias, string[] @short, string attribute, List<string> arguments)
717-
{
718-
ArgumentException.ThrowIfNullOrEmpty(name);
719-
ArgumentException.ThrowIfNullOrEmpty(description);
720-
721-
Name = name;
722-
Alias = alias;
723-
Short = @short;
724-
Attribute = attribute;
725-
Description = description;
726-
Arguments = arguments;
727-
}
728-
}
729-
730-
public sealed class Command
731-
{
732-
public List<Option> Options { get; }
733-
public string Examples { get; }
734-
public string Name { get; }
735-
public string Description { get; }
736-
737-
public Command(string name, string description, List<Option> options, string examples)
738-
{
739-
ArgumentException.ThrowIfNullOrEmpty(name);
740-
ArgumentException.ThrowIfNullOrEmpty(description);
741-
ArgumentNullException.ThrowIfNull(options);
742-
743-
Options = options;
744-
Examples = examples;
745-
Name = name;
746-
Description = description;
747-
}
748-
749-
public Option FindOption(string name)
750-
{
751-
foreach (Option option in Options)
752-
{
753-
if (name.StartsWith("--"))
754-
{
755-
if (string.Equals(option.Name, name, StringComparison.OrdinalIgnoreCase))
756-
{
757-
return option;
758-
}
759-
760-
if (option.Alias is not null)
761-
{
762-
foreach (string alias in option.Alias)
763-
{
764-
if (string.Equals(alias, name, StringComparison.OrdinalIgnoreCase))
765-
{
766-
return option;
767-
}
768-
}
769-
}
770-
}
771-
else if (option.Short is not null)
772-
{
773-
foreach (string s in option.Short)
774-
{
775-
if (string.Equals(s, name, StringComparison.OrdinalIgnoreCase))
776-
{
777-
return option;
778-
}
779-
}
780-
}
781-
}
782-
783-
return null;
784-
}
785-
}

shell/agents/Microsoft.Azure.Agent/Schema.cs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,19 +224,50 @@ internal class ResponseData
224224

225225
internal class ArgumentPlaceholder
226226
{
227-
internal ArgumentPlaceholder(string query, ResponseData data)
227+
internal ArgumentPlaceholder(string query, ResponseData data, HttpClient httpClient)
228228
{
229229
ArgumentException.ThrowIfNullOrEmpty(query);
230230
ArgumentNullException.ThrowIfNull(data);
231231

232232
Query = query;
233233
ResponseData = data;
234-
DataRetriever = new(data);
234+
DataRetriever = new(data, httpClient);
235235
}
236236

237237
public string Query { get; set; }
238238
public ResponseData ResponseData { get; set; }
239239
public DataRetriever DataRetriever { get; }
240240
}
241241

242+
internal class AzCLIParameter
243+
{
244+
public List<string> Options { get; set; }
245+
public List<string> Choices { get; set; }
246+
public bool Required { get; set; }
247+
248+
[JsonPropertyName("has_completer")]
249+
public bool HasCompleter { get; set; }
250+
}
251+
252+
internal class AzCLICommand
253+
{
254+
public List<AzCLIParameter> Parameters { get; set; }
255+
256+
public AzCLIParameter FindParameter(string name)
257+
{
258+
foreach (var param in Parameters)
259+
{
260+
foreach (var option in param.Options)
261+
{
262+
if (option.Equals(name, StringComparison.OrdinalIgnoreCase))
263+
{
264+
return param;
265+
}
266+
}
267+
}
268+
269+
return null;
270+
}
271+
}
272+
242273
#endregion

shell/agents/Microsoft.Azure.Agent/Utils.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ namespace Microsoft.Azure.Agent;
44

55
internal static class Utils
66
{
7+
internal const string JsonContentType = "application/json";
8+
79
private static readonly JsonSerializerOptions s_jsonOptions;
810
private static readonly JsonSerializerOptions s_humanReadableOptions;
911

0 commit comments

Comments
 (0)