diff --git a/shell/AIShell.Abstraction/ILLMAgent.cs b/shell/AIShell.Abstraction/ILLMAgent.cs index 29b09502..a4e3fb2f 100644 --- a/shell/AIShell.Abstraction/ILLMAgent.cs +++ b/shell/AIShell.Abstraction/ILLMAgent.cs @@ -137,11 +137,12 @@ public interface ILLMAgent : IDisposable void Initialize(AgentConfig config); /// - /// Refresh the current chat by starting a new chat session. + /// Refresh the current chat or force starting a new chat session. /// This method allows an agent to reset chat states, interact with user for authentication, print welcome message, and more. /// /// The interface for interacting with the shell. - Task RefreshChatAsync(IShell shell); + /// Whether or not to force creating a new chat session. + Task RefreshChatAsync(IShell shell, bool force); /// /// Initiates a chat with the AI, using the provided input and shell. diff --git a/shell/AIShell.Kernel/Command/RefreshCommand.cs b/shell/AIShell.Kernel/Command/RefreshCommand.cs index 65b52886..55c8222f 100644 --- a/shell/AIShell.Kernel/Command/RefreshCommand.cs +++ b/shell/AIShell.Kernel/Command/RefreshCommand.cs @@ -19,6 +19,6 @@ private void RefreshAction() var shell = (Shell)Shell; shell.ShowBanner(); shell.ShowLandingPage(); - shell.ActiveAgent.Impl.RefreshChatAsync(Shell).GetAwaiter().GetResult(); + shell.ActiveAgent.Impl.RefreshChatAsync(Shell, force: true).GetAwaiter().GetResult(); } } diff --git a/shell/AIShell.Kernel/Shell.cs b/shell/AIShell.Kernel/Shell.cs index bd2bdb6f..dd99b309 100644 --- a/shell/AIShell.Kernel/Shell.cs +++ b/shell/AIShell.Kernel/Shell.cs @@ -555,7 +555,7 @@ internal async Task RunREPLAsync() if (_shouldRefresh) { _shouldRefresh = false; - await agent?.Impl.RefreshChatAsync(this); + await agent?.Impl.RefreshChatAsync(this, force: false); } if (Regenerate) @@ -670,7 +670,7 @@ internal async Task RunOnceAsync(string prompt) try { - await _activeAgent.Impl.RefreshChatAsync(this); + await _activeAgent.Impl.RefreshChatAsync(this, force: false); await _activeAgent.Impl.ChatAsync(prompt, this); } catch (OperationCanceledException) diff --git a/shell/agents/AIShell.Azure.Agent/AzCLI/AzCLIAgent.cs b/shell/agents/AIShell.Azure.Agent/AzCLI/AzCLIAgent.cs index d185f523..10afbd9d 100644 --- a/shell/agents/AIShell.Azure.Agent/AzCLI/AzCLIAgent.cs +++ b/shell/agents/AIShell.Azure.Agent/AzCLI/AzCLIAgent.cs @@ -101,7 +101,7 @@ public void OnUserAction(UserActionPayload actionPayload) }); } - public Task RefreshChatAsync(IShell shell) + public Task RefreshChatAsync(IShell shell, bool force) { // Reset the history so the subsequent chat can start fresh. _chatService.ChatHistory.Clear(); diff --git a/shell/agents/AIShell.Azure.Agent/AzPS/AzPSAgent.cs b/shell/agents/AIShell.Azure.Agent/AzPS/AzPSAgent.cs index 2b64d45d..6ea778b8 100644 --- a/shell/agents/AIShell.Azure.Agent/AzPS/AzPSAgent.cs +++ b/shell/agents/AIShell.Azure.Agent/AzPS/AzPSAgent.cs @@ -105,7 +105,7 @@ public void OnUserAction(UserActionPayload actionPayload) }); } - public Task RefreshChatAsync(IShell shell) + public Task RefreshChatAsync(IShell shell, bool force) { // Reset the history so the subsequent chat can start fresh. _chatService.ChatHistory.Clear(); diff --git a/shell/agents/AIShell.Interpreter.Agent/Agent.cs b/shell/agents/AIShell.Interpreter.Agent/Agent.cs index b2bbcbdb..62b0da85 100644 --- a/shell/agents/AIShell.Interpreter.Agent/Agent.cs +++ b/shell/agents/AIShell.Interpreter.Agent/Agent.cs @@ -72,7 +72,7 @@ public void Initialize(AgentConfig config) } /// - public Task RefreshChatAsync(IShell shell) + public Task RefreshChatAsync(IShell shell, bool force) { // Reload the setting file if needed. ReloadSettings(); diff --git a/shell/agents/AIShell.Ollama.Agent/OllamaAgent.cs b/shell/agents/AIShell.Ollama.Agent/OllamaAgent.cs index 15495273..36aa6140 100644 --- a/shell/agents/AIShell.Ollama.Agent/OllamaAgent.cs +++ b/shell/agents/AIShell.Ollama.Agent/OllamaAgent.cs @@ -87,7 +87,7 @@ public void OnUserAction(UserActionPayload actionPayload) {} /// Refresh the current chat by starting a new chat session. /// This method allows an agent to reset chat states, interact with user for authentication, print welcome message, and more. /// - public Task RefreshChatAsync(IShell shell) => Task.CompletedTask; + public Task RefreshChatAsync(IShell shell, bool force) => Task.CompletedTask; /// /// Main chat function that takes the users input and passes it to the LLM and renders it. diff --git a/shell/agents/AIShell.OpenAI.Agent/Agent.cs b/shell/agents/AIShell.OpenAI.Agent/Agent.cs index 105ee73e..4da54ccc 100644 --- a/shell/agents/AIShell.OpenAI.Agent/Agent.cs +++ b/shell/agents/AIShell.OpenAI.Agent/Agent.cs @@ -77,7 +77,7 @@ public void Initialize(AgentConfig config) public void OnUserAction(UserActionPayload actionPayload) {} /// - public Task RefreshChatAsync(IShell shell) + public Task RefreshChatAsync(IShell shell, bool force) { // Reload the setting file if needed. ReloadSettings(); diff --git a/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs b/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs index 132cfc54..034e84df 100644 --- a/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs +++ b/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs @@ -2,6 +2,7 @@ using System.Text; using AIShell.Abstraction; +using Azure.Identity; using Serilog; namespace Microsoft.Azure.Agent; @@ -115,10 +116,39 @@ public void Initialize(AgentConfig config) public bool CanAcceptFeedback(UserAction action) => false; public void OnUserAction(UserActionPayload actionPayload) {} - public async Task RefreshChatAsync(IShell shell) + public async Task RefreshChatAsync(IShell shell, bool force) { - // Refresh the chat session. - await _chatSession.RefreshAsync(shell.Host, shell.CancellationToken); + IHost host = shell.Host; + CancellationToken cancellationToken = shell.CancellationToken; + + try + { + string welcome = await host.RunWithSpinnerAsync( + status: "Initializing ...", + spinnerKind: SpinnerKind.Processing, + func: async context => await _chatSession.RefreshAsync(context, force, cancellationToken) + ).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(welcome)) + { + host.WriteLine(welcome); + } + } + catch (OperationCanceledException) + { + host.WriteErrorLine("Operation cancelled. Please run '/refresh' to start a new conversation."); + } + catch (CredentialUnavailableException) + { + host.WriteErrorLine($"Failed to start a chat session: Access token not available."); + host.WriteErrorLine($"The '{Name}' agent depends on the Azure CLI credential to acquire access token. Please run 'az login' from a command-line shell to setup account."); + } + catch (Exception e) + { + host.WriteErrorLine($"Failed to start a chat session: {e.Message}\n{e.StackTrace}") + .WriteErrorLine() + .WriteErrorLine("Please try '/refresh' to start a new conversation."); + } } public async Task ChatAsync(string input, IShell shell) diff --git a/shell/agents/Microsoft.Azure.Agent/ChatSession.cs b/shell/agents/Microsoft.Azure.Agent/ChatSession.cs index 1fa37442..17a60723 100644 --- a/shell/agents/Microsoft.Azure.Agent/ChatSession.cs +++ b/shell/agents/Microsoft.Azure.Agent/ChatSession.cs @@ -5,15 +5,17 @@ using System.Text.Json.Nodes; using AIShell.Abstraction; +using Azure.Core; +using Azure.Identity; using Serilog; namespace Microsoft.Azure.Agent; internal class ChatSession : IDisposable { - private const string DL_TOKEN_URL = "https://directline.botframework.com/v3/directline/tokens/generate"; + private const string DL_TOKEN_URL = "https://copilotweb.production.portalrp.azure.com/api/conversations/start?api-version=2024-11-15"; private const string REFRESH_TOKEN_URL = "https://directline.botframework.com/v3/directline/tokens/refresh"; - internal const string CONVERSATION_URL = "https://directline.botframework.com/v3/directline/conversations"; + private const string CONVERSATION_URL = "https://directline.botframework.com/v3/directline/conversations"; private string _token; private string _streamUrl; @@ -22,7 +24,6 @@ internal class ChatSession : IDisposable private DateTime _expireOn; private AzureCopilotReceiver _copilotReceiver; - private readonly string _dl_secret; private readonly HttpClient _httpClient; private readonly Dictionary _flights; @@ -30,7 +31,6 @@ internal class ChatSession : IDisposable internal ChatSession(HttpClient httpClient) { - _dl_secret = Environment.GetEnvironmentVariable("DL_SECRET"); _httpClient = httpClient; // Keys and values for flights are from the portal request. @@ -59,33 +59,46 @@ internal ChatSession(HttpClient httpClient) ["chitchatprompt"] = true, // TODO: the streaming is slow and not sending chunks, very clumsy for now. // ["streamresponse"] = true, + // ["azurepluginstore"] = true, }; } - internal async Task RefreshAsync(IHost host, CancellationToken cancellationToken) + internal async Task RefreshAsync(IStatusContext context, bool force, CancellationToken cancellationToken) { - try - { - await GenerateTokenAsync(host, cancellationToken); - await StartConversationAsync(host, cancellationToken); - } - catch (Exception e) + if (_token is not null) { - Reset(); - if (e is OperationCanceledException) + if (force) { - host.WriteErrorLine() - .WriteErrorLine("Operation cancelled. Please run '/refresh' to start a new conversation."); + // End the existing conversation. + context.Status("End current chat ..."); + EndConversation(); + Reset(); } else { - host.WriteErrorLine() - .WriteErrorLine($"Failed to start a conversation due to the following error: {e.Message}\n{e.StackTrace}") - .WriteErrorLine() - .WriteErrorLine("Please try '/refresh' to start a new conversation.") - .WriteErrorLine(); + try + { + context.Status("Refresh DirectLine token ..."); + await RenewTokenAsync(cancellationToken); + return null; + } + catch (Exception) + { + // Refreshing failed. We will create a new chat session. + } } } + + try + { + _token = await GenerateTokenAsync(context, cancellationToken); + return await StartConversationAsync(context, cancellationToken); + } + catch (Exception) + { + Reset(); + throw; + } } private void Reset() @@ -100,43 +113,38 @@ private void Reset() _copilotReceiver = null; } - private async Task GenerateTokenAsync(IHost host, CancellationToken cancellationToken) + private async Task GenerateTokenAsync(IStatusContext context, CancellationToken cancellationToken) { - // TODO: use spinner when generating token. Also use interaction for authentication is needed. - string manualToken = Environment.GetEnvironmentVariable("DL_TOKEN"); - if (!string.IsNullOrEmpty(manualToken)) - { - _token = manualToken; - return; - } - - if (string.IsNullOrEmpty(_dl_secret)) - { - throw new TokenRequestException("You have to manually grab the Direct Line token from portal and set it to the environment variable 'DL_TOKEN' until we figure out authentication."); - } - - // TODO: figure out how to get the token when copilot API is ready. - HttpRequestMessage request = new(HttpMethod.Post, DL_TOKEN_URL); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _dl_secret); + context.Status("Get Azure CLI login token ..."); + // Get an access token from the AzCLI login, using the specific audience guid. + AccessToken accessToken = await new AzureCliCredential() + .GetTokenAsync( + new TokenRequestContext(["7000789f-b583-4714-ab18-aef39213018a/.default"]), + cancellationToken); + + context.Status("Request for DirectLine token ..."); + StringContent content = new("{\"conversationType\": \"Chat\"}", Encoding.UTF8, Utils.JsonContentType); + HttpRequestMessage request = new(HttpMethod.Post, DL_TOKEN_URL) { Content = content }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - Stream content = await response.Content.ReadAsStreamAsync(cancellationToken); - TokenPayload tpl = JsonSerializer.Deserialize(content, Utils.JsonOptions); - - _token = tpl.Token; + using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var dlToken = JsonSerializer.Deserialize(stream, Utils.JsonOptions); + return dlToken.DirectLine.Token; } - private async Task StartConversationAsync(IHost host, CancellationToken cancellationToken) + private async Task StartConversationAsync(IStatusContext context, CancellationToken cancellationToken) { + context.Status("Start a new chat session ..."); HttpRequestMessage request = new(HttpMethod.Post, CONVERSATION_URL); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - Stream content = await response.Content.ReadAsStreamAsync(cancellationToken); + using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken); SessionPayload spl = JsonSerializer.Deserialize(content, Utils.JsonOptions); _token = spl.Token; @@ -156,22 +164,39 @@ private async Task StartConversationAsync(IHost host, CancellationToken cancella activity.ExtractMetadata(out _, out ConversationState conversationState); int chatNumber = conversationState.DailyConversationNumber; int requestNumber = conversationState.TurnNumber; - - host.WriteLine($"\n{activity.Text}\nThis is chat #{chatNumber}, request #{requestNumber}.\n"); - return; + return $"{activity.Text}\nThis is chat #{chatNumber}, request #{requestNumber}.\n"; } } } - private void RenewToken(CancellationToken cancellationToken) + private TokenHealth CheckDLTokenHealth() { + ArgumentNullException.ThrowIfNull(_token, nameof(_token)); + var now = DateTime.UtcNow; if (now > _expireOn || now.AddMinutes(2) >= _expireOn) + { + return TokenHealth.Expired; + } + + if (now.AddMinutes(10) < _expireOn) + { + return TokenHealth.Good; + } + + return TokenHealth.TimeToRefresh; + } + + private async Task RenewTokenAsync(CancellationToken cancellationToken) + { + TokenHealth health = CheckDLTokenHealth(); + if (health is TokenHealth.Expired) { Reset(); throw new TokenRequestException("The chat session has expired. Please start a new chat session."); } - else if (now.AddMinutes(10) < _expireOn) + + if (health is TokenHealth.Good) { return; } @@ -181,14 +206,14 @@ private void RenewToken(CancellationToken cancellationToken) HttpRequestMessage request = new(HttpMethod.Post, REFRESH_TOKEN_URL); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); - var response = _httpClient.Send(request, cancellationToken); + var response = await _httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - Stream content = response.Content.ReadAsStream(cancellationToken); - TokenPayload tpl = JsonSerializer.Deserialize(content, Utils.JsonOptions); + using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken); + RefreshDLToken dlToken = JsonSerializer.Deserialize(content, Utils.JsonOptions); - _token = tpl.Token; - _expireOn = DateTime.UtcNow.AddSeconds(tpl.ExpiresIn); + _token = dlToken.Token; + _expireOn = DateTime.UtcNow.AddSeconds(dlToken.ExpiresIn); } catch (Exception e) when (e is not OperationCanceledException) { @@ -250,19 +275,34 @@ private async Task SendQueryToCopilot(string input, CancellationToken ca response.EnsureSuccessStatusCode(); // Retrieve the activity id of this query. - Stream content = await response.Content.ReadAsStreamAsync(cancellationToken); + using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken); JsonObject contentObj = JsonNode.Parse(content).AsObject(); return contentObj["id"].ToString(); } + private void EndConversation() + { + if (_token is null || CheckDLTokenHealth() is TokenHealth.Expired) + { + // Chat session already expired, no need to send request to end the conversation. + return; + } + + var content = new StringContent("{\"type\":\"endOfConversation\",\"from\":{\"id\":\"user\"}}", Encoding.UTF8, Utils.JsonContentType); + var request = new HttpRequestMessage(HttpMethod.Post, _conversationUrl) { Content = content }; + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + _httpClient.Send(request, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None); + } + internal async Task GetChatResponseAsync(string input, IStatusContext context, CancellationToken cancellationToken) { try { - // context?.Status("Refreshing Token ..."); - // RenewToken(cancellationToken); + context?.Status("Refreshing Token ..."); + await RenewTokenAsync(cancellationToken); - context?.Status("Generating ..."); + context?.Status("Sending query ..."); string activityId = await SendQueryToCopilot(input, cancellationToken); while (true) @@ -313,6 +353,7 @@ internal async Task GetChatResponseAsync(string input, IStatusC public void Dispose() { + EndConversation(); _copilotReceiver?.Dispose(); } } diff --git a/shell/agents/Microsoft.Azure.Agent/Command.cs b/shell/agents/Microsoft.Azure.Agent/Command.cs index 6236e2e1..f79800f2 100644 --- a/shell/agents/Microsoft.Azure.Agent/Command.cs +++ b/shell/agents/Microsoft.Azure.Agent/Command.cs @@ -110,7 +110,7 @@ private void ReplaceAction() { // Add quotes for the value if needed. value = value.Trim(); - if (value.StartsWith('-') || value.Contains(' ')) + if (value.StartsWith('-') || value.Contains(' ') || value.Contains('|')) { value = $"\"{value}\""; } diff --git a/shell/agents/Microsoft.Azure.Agent/Schema.cs b/shell/agents/Microsoft.Azure.Agent/Schema.cs index 6e60ec67..1424a927 100644 --- a/shell/agents/Microsoft.Azure.Agent/Schema.cs +++ b/shell/agents/Microsoft.Azure.Agent/Schema.cs @@ -51,7 +51,14 @@ internal static void NewSettingFile(string path) } } -internal class TokenPayload +internal enum TokenHealth +{ + Good, + TimeToRefresh, + Expired +} + +internal class RefreshDLToken { public string ConversationId { get; set; } public string Token { get; set; } @@ -60,6 +67,21 @@ internal class TokenPayload public int ExpiresIn { get; set; } } +internal class NewDLToken +{ + public string Endpoint { get; set; } + public string Token { get; set; } + public int TokenExpiryTimeInSeconds { get; set; } +} + +internal class DirectLineToken +{ + public string Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime LastModifiedAt { get; set; } + public NewDLToken DirectLine { get; set; } +} + internal class SessionPayload { public string ConversationId { get; set; } diff --git a/shell/shell.common.props b/shell/shell.common.props index 0e1b3402..7e98aa6a 100644 --- a/shell/shell.common.props +++ b/shell/shell.common.props @@ -8,7 +8,7 @@ net8.0 enable 12.0 - 0.1.0-alpha.16 + 0.1.0-alpha.19 true true