File: Engine\AgentContext.cs
Web Access
Project: src\aspnetcore\src\Components\AI\src\Microsoft.AspNetCore.Components.AI.csproj (Microsoft.AspNetCore.Components.AI)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.Extensions.AI;
 
namespace Microsoft.AspNetCore.Components.AI;
 
/// <summary>
/// Owns the state of a conversation: the turns rendered by the UI, the current
/// <see cref="ConversationStatus"/>, and the notifications the components subscribe to.
/// </summary>
/// <example>
/// <code>
/// var context = new AgentContext(agent);
/// using var subscription = context.RegisterOnStatusChanged(status => Console.WriteLine(status));
/// await context.SendMessageAsync("Hello");
/// </code>
/// </example>
public class AgentContext : IDisposable
{
    private readonly UIAgent _agent;
    private readonly List<ConversationTurn> _turns = new();
    private readonly List<Action<ConversationTurn>> _turnAddedCallbacks = new();
    private readonly List<Action<ConversationStatus>> _statusChangedCallbacks = new();
    private readonly List<Action<ConversationTurn, ContentBlock>> _blockAddedCallbacks = new();
    private CancellationTokenSource? _streamingCts;
    private ChatMessage? _lastMessage;
    private bool _disposed;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="AgentContext"/> class.
    /// </summary>
    /// <param name="agent">The agent that produces the responses for this conversation.</param>
    public AgentContext(UIAgent agent)
    {
        ArgumentNullException.ThrowIfNull(agent);
        _agent = agent;
    }
 
    /// <summary>
    /// Gets the turns of this conversation, oldest first.
    /// </summary>
    public IReadOnlyList<ConversationTurn> Turns => _turns;
 
    /// <summary>
    /// Gets the current status of the conversation.
    /// </summary>
    public ConversationStatus Status { get; private set; }
 
    /// <summary>
    /// Gets the exception that failed the last turn, when <see cref="Status"/> is
    /// <see cref="ConversationStatus.Error"/>.
    /// </summary>
    public Exception? Error { get; private set; }
 
    /// <summary>
    /// Sends a text message and streams the response into a new turn.
    /// </summary>
    /// <param name="text">The message text.</param>
    /// <param name="cancellationToken">A token that cancels the response.</param>
    /// <returns>A task that completes when the turn finishes.</returns>
    public Task SendMessageAsync(string text, CancellationToken cancellationToken = default)
    {
        return SendMessageAsync(new ChatMessage(ChatRole.User, text), cancellationToken);
    }
 
    /// <summary>
    /// Sends a message and streams the response into a new turn.
    /// </summary>
    /// <param name="message">The message to send.</param>
    /// <param name="cancellationToken">A token that cancels the response.</param>
    /// <returns>A task that completes when the turn finishes.</returns>
    public async Task SendMessageAsync(ChatMessage message, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(message);
 
        if (Status == ConversationStatus.Streaming)
        {
            throw new InvalidOperationException("A message is already being processed.");
        }
 
        _lastMessage = message;
 
        var turn = new ConversationTurn();
        _turns.Add(turn);
        NotifyTurnAdded(turn);
 
        _streamingCts?.Dispose();
        _streamingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 
        await StreamIntoTurnAsync(message, turn, _streamingCts.Token, cancellationToken);
    }
 
    /// <summary>
    /// Replays the last message after a failed turn.
    /// </summary>
    /// <param name="cancellationToken">A token that cancels the response.</param>
    /// <returns>A task that completes when the turn finishes.</returns>
    public async Task RetryAsync(CancellationToken cancellationToken = default)
    {
        if (Status != ConversationStatus.Error)
        {
            throw new InvalidOperationException(
                $"RetryAsync requires Status == Error, but Status is {Status}.");
        }
 
        var turn = _turns[^1];
        turn.ClearResponseBlocks();
 
        _streamingCts?.Dispose();
        _streamingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 
        await StreamIntoTurnAsync(_lastMessage!, turn, _streamingCts.Token, cancellationToken);
    }
 
    /// <summary>
    /// Stops the response that is currently streaming, if any.
    /// </summary>
    /// <returns>A task that completes once cancellation has been requested.</returns>
    public Task CancelAsync()
    {
        if (Status is ConversationStatus.Idle or ConversationStatus.Error)
        {
            return Task.CompletedTask;
        }
 
        _streamingCts?.Cancel();
        return Task.CompletedTask;
    }
 
    /// <summary>
    /// Registers a callback invoked when a turn is added to the conversation.
    /// </summary>
    /// <param name="callback">The callback to invoke.</param>
    /// <returns>A registration that removes the callback when disposed.</returns>
    public IDisposable RegisterOnTurnAdded(Action<ConversationTurn> callback)
    {
        ArgumentNullException.ThrowIfNull(callback);
        _turnAddedCallbacks.Add(callback);
        return new CallbackRegistration<Action<ConversationTurn>>(_turnAddedCallbacks, callback);
    }
 
    /// <summary>
    /// Registers a callback invoked when <see cref="Status"/> changes.
    /// </summary>
    /// <param name="callback">The callback to invoke.</param>
    /// <returns>A registration that removes the callback when disposed.</returns>
    public IDisposable RegisterOnStatusChanged(Action<ConversationStatus> callback)
    {
        ArgumentNullException.ThrowIfNull(callback);
        _statusChangedCallbacks.Add(callback);
        return new CallbackRegistration<Action<ConversationStatus>>(_statusChangedCallbacks, callback);
    }
 
    /// <summary>
    /// Registers a callback invoked when a block is added to a turn.
    /// </summary>
    /// <param name="callback">The callback to invoke.</param>
    /// <returns>A registration that removes the callback when disposed.</returns>
    public IDisposable RegisterOnBlockAdded(Action<ConversationTurn, ContentBlock> callback)
    {
        ArgumentNullException.ThrowIfNull(callback);
        _blockAddedCallbacks.Add(callback);
        return new CallbackRegistration<Action<ConversationTurn, ContentBlock>>(_blockAddedCallbacks, callback);
    }
 
    /// <summary>
    /// Releases the resources used by this context and stops any streaming response.
    /// </summary>
    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }
 
        _disposed = true;
        _streamingCts?.Cancel();
        _streamingCts?.Dispose();
        _turnAddedCallbacks.Clear();
        _statusChangedCallbacks.Clear();
        _blockAddedCallbacks.Clear();
        GC.SuppressFinalize(this);
    }
 
    private async Task StreamIntoTurnAsync(
        ChatMessage message,
        ConversationTurn turn,
        CancellationToken cancellationToken,
        CancellationToken callerToken)
    {
        Status = ConversationStatus.Streaming;
        Error = null;
        NotifyStatusChanged();
 
        try
        {
            await foreach (var block in _agent.SendMessageAsync(message, cancellationToken)
                .WithCancellation(cancellationToken))
            {
                if (block.Role == message.Role)
                {
                    turn.AddRequestBlock(block);
                }
                else
                {
                    turn.AddResponseBlock(block);
                }
 
                NotifyBlockAdded(turn, block);
            }
 
            Status = ConversationStatus.Idle;
            if (cancellationToken.IsCancellationRequested)
            {
                turn.ClearResponseBlocks();
            }
            NotifyStatusChanged();
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            turn.ClearResponseBlocks();
            Status = ConversationStatus.Idle;
            NotifyStatusChanged();
        }
        catch (Exception ex)
        {
            // A failing turn is surfaced as conversation state (Status/Error) rather than a
            // faulted Task: the UI renders the error and RetryAsync replays the last message.
            // This is the engine's error contract, not a swallowed exception.
            Error = ex;
            Status = ConversationStatus.Error;
            NotifyStatusChanged();
            return;
        }
 
        // If the caller's own token requested cancellation, surface it so the returned Task
        // completes as canceled. Cancellation driven by CancelAsync() (the internal token only)
        // is a graceful stop and completes normally.
        callerToken.ThrowIfCancellationRequested();
    }
 
    private void NotifyStatusChanged()
    {
        var snapshot = _statusChangedCallbacks.ToArray();
        foreach (var callback in snapshot)
        {
            callback(Status);
        }
    }
 
    private void NotifyTurnAdded(ConversationTurn turn)
    {
        var snapshot = _turnAddedCallbacks.ToArray();
        foreach (var callback in snapshot)
        {
            callback(turn);
        }
    }
 
    private void NotifyBlockAdded(ConversationTurn turn, ContentBlock block)
    {
        var snapshot = _blockAddedCallbacks.ToArray();
        foreach (var callback in snapshot)
        {
            callback(turn, block);
        }
    }
 
    private sealed class CallbackRegistration<T> : IDisposable
    {
        private List<T>? _list;
        private T? _callback;
 
        internal CallbackRegistration(List<T> list, T callback)
        {
            _list = list;
            _callback = callback;
        }
 
        public void Dispose()
        {
            if (_list is not null && _callback is not null)
            {
                _list.Remove(_callback);
                _list = null;
                _callback = default;
            }
        }
    }
}