← All source files

V2 / df3fc286644d

ProviderClient.cs

Lilith/Core/Providers/ProviderClient.cs

Changed in this version
CS
namespace Lilith.Core.Providers;

using System.Text.Json;
using Lilith.Core.Tools;

/// <summary>Conversation operations reject overlap rather than mixing sessions or snapshots.</summary>
public abstract class ProviderClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : IDisposable
{
    private readonly bool _ownsHttp = httpClient is null;
    private readonly SemaphoreSlim _operation = new(1, 1);
    private bool _disposed;
    private readonly Dictionary<string, ILilithTool> _tools = new(StringComparer.Ordinal);
    protected object[]? ToolDefinitions => _tools.Count == 0 ? null : _tools.Values.Select(t => (object)new
    {
        type = "function", function = new { name = t.Name, description = t.Description, parameters = t.Parameters }
    }).ToArray();

    public void RegisterTool(ILilithTool tool)
    {
        using var operation = EnterOperation();
        ArgumentNullException.ThrowIfNull(tool);
        if (string.IsNullOrWhiteSpace(tool.Name) || tool.Name.Length > 64 ||
            tool.Name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')) ||
            tool.Parameters.ValueKind != JsonValueKind.Object)
            throw new ArgumentException("A tool requires a valid function name and JSON parameter schema.", nameof(tool));
        if (!_tools.TryAdd(tool.Name, tool)) throw new ArgumentException($"Tool '{tool.Name}' is already registered.", nameof(tool));
    }
    public HttpClient Http { get; } = httpClient ?? new() { Timeout = TimeSpan.FromMinutes(20) };
    public string Model { get; set; } = "gemma4";
    protected int NumCtx { get; set; } = 131072;
    public int ContextMax => NumCtx;
    public string SystemPrompt { get; protected set; } = string.Empty;
    /// <summary>Inspect only while idle. Use conversation methods to change an active session.</summary>
    public ChatHistory History { get; } = [];
    public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore();
    public Logger Logger { get; } = logger;
    public string AgentName { get; set; } = "Lilith";
    public int ContextUsagePercent { get; private set; }
    public int CurrentContextTokens { get; private set; }
    public int ContextWarningThreshold { get; set; } = 60;
    public event Action<int>? OnContextHighUsage;

    public void ContextHighUsage(Func<int, string> message, string color = LogColors.Orange) =>
        OnContextHighUsage += pct => Logger.WriteLine("System", message(pct), color);

    protected void InitializeConversation()
    {
        History.Add("system", SystemPrompt);
        ContextHighUsage(pct => $"\u26a0 Context window is {pct}% full — history is getting long.");
    }

    private IDisposable EnterOperation()
    {
        var operation = _operation.EnterOrThrow("A conversation operation is already in progress.");
        if (_disposed) { operation.Dispose(); throw new ObjectDisposedException(GetType().Name); }
        return operation;
    }

    public void NewConversation()
    {
        using var operation = EnterOperation();
        SaveCore();
        HistoryStore.StartNewSession();
        ClearCore();
        Logger.WriteLine("System", "New conversation started.", LogColors.Cyan);
    }

    public void SaveConversation() => SaveCurrentConversation();
    public IReadOnlyList<ChatSessionSummary> ListConversations() => ListSavedConversations();
    public bool LoadConversation(int displayIndex) => LoadSavedConversation(displayIndex);
    public bool LoadConversation(string id) => LoadSavedConversation(id);
    public void ClearHistory() { using var operation = EnterOperation(); ClearCore(); }

    private void ClearCore()
    {
        History.Clear();
        ResetUsage();
        if (!string.IsNullOrWhiteSpace(SystemPrompt)) History.Add("system", SystemPrompt);
    }

    private void ResetUsage() { CurrentContextTokens = 0; ContextUsagePercent = 0; }
    public void StartNewSession() { using var operation = EnterOperation(); HistoryStore.StartNewSession(); }
    public void SaveCurrentConversation() { using var operation = EnterOperation(); SaveCore(); }

    private void SaveCore()
    {
        try
        {
            if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content)))
                HistoryStore.SaveSession(Model, History);
        }
        catch (Exception ex) { Logger.WriteWarning("History", $"Could not save chat history: {ex.Message}"); }
    }

    public IReadOnlyList<ChatSessionSummary> ListSavedConversations()
    {
        using var operation = EnterOperation();
        return HistoryStore.ListSessions();
    }

    public bool LoadSavedConversation(int displayIndex)
    {
        using var operation = EnterOperation();
        var sessions = HistoryStore.ListSessions();
        return displayIndex >= 1 && displayIndex <= sessions.Count && LoadCore(sessions[displayIndex - 1].Id);
    }

    public bool LoadSavedConversation(string id) { using var operation = EnterOperation(); return LoadCore(id); }

    private bool LoadCore(string id)
    {
        string previousId = HistoryStore.CurrentSessionId;
        try
        {
            var session = HistoryStore.LoadSession(id);
            if (session is null) return false;
            var messages = session.Messages.Select(m => m.ToChatMessage()).ToList();
            if (messages[0].Role != "system")
            {
                if (string.IsNullOrWhiteSpace(SystemPrompt)) throw new InvalidDataException("System prompt content is missing.");
                messages.Insert(0, new ChatMessage("system", SystemPrompt));
            }
            History.Clear();
            History.AddRange(messages);
            ResetUsage();
            return true;
        }
        catch (Exception ex)
        {
            HistoryStore.UseSession(previousId);
            Logger.WriteWarning("History", $"Could not load chat history: {ex.Message}");
            return false;
        }
    }

    protected abstract Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages);

    public async Task<string> StreamReply(string userInput, List<string>? images = null)
    {
        using var operation = EnterOperation();
        if (string.IsNullOrWhiteSpace(userInput)) throw new ArgumentException("User input cannot be blank.", nameof(userInput));
        var userMessage = new ChatMessage("user", userInput, Images: images is null ? null : [.. images]);
        var requestHistory = History.Select(m => m.Snapshot()).ToList();
        requestHistory.Add(userMessage);
        Logger.WriteLine("User", $"User: {userInput}", LogColors.Green);
        ChatChunk? chunk;
        int originalCount = requestHistory.Count - 1;
        int rounds = 0;
        while (true)
        {
            chunk = await GenerateReplyAsync(requestHistory);
            if (chunk is not { Done: true, Message.Role: "assistant" } ||
                chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
                throw new InvalidDataException("The model provider returned an invalid assistant response.");
            if (chunk.Message.ToolCalls is not { Count: > 0 } calls) break;
            if (++rounds > 8 || calls.Count > 16)
                throw new InvalidDataException("The model exceeded the tool execution limit.");
            foreach (var call in calls)
                if (call?.Function is null || string.IsNullOrWhiteSpace(call.Function.Name) ||
                    call.Function.Arguments.ValueKind != JsonValueKind.Object)
                    throw new InvalidDataException("The model returned a malformed tool call.");
            requestHistory.Add(chunk.Message with { Content = chunk.Message.Content ?? string.Empty });
            foreach (var call in calls)
            {
                string result;
                if (!_tools.TryGetValue(call.Function.Name, out var tool))
                    result = JsonSerializer.Serialize(new { error = "Unknown tool: " + call.Function.Name });
                else
                {
                    using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
                    try
                    {
                        // Await actual completion: custom tools must cooperate with cancellation.
                        result = await tool.ExecuteAsync(call.Function.Arguments, deadline.Token);
                        deadline.Token.ThrowIfCancellationRequested();
                    }
                    catch (Exception ex) when (ex is not OutOfMemoryException)
                    {
                        result = JsonSerializer.Serialize(new { error = ex is OperationCanceledException ? "Tool execution canceled or timed out." : ex.Message });
                    }
                }
                requestHistory.Add(new ChatMessage("tool", result ?? string.Empty, ToolName: call.Function.Name, ToolCallId: call.Id));
            }
        }
        if (chunk is not { Done: true, Message: { Role: "assistant", Content: not null } } ||
            string.IsNullOrWhiteSpace(chunk.Message.Content) || chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
            throw new InvalidDataException("The model provider did not return a complete nonempty assistant reply with valid token counts.");
        string reply = chunk.Message.Content;
        // Commit only after a successful final answer.
        requestHistory[originalCount] = userMessage with { Tokens = chunk.PromptTokens };
        History.AddRange(requestHistory.Skip(originalCount).Select(m => m.Snapshot()));
        History.Add("assistant", reply, chunk.CompletionTokens);
        SaveCore();
        Logger.WriteLine(AgentName, $"{AgentName}: {reply}", LogColors.Yellow);
        ResetUsage();
        if (chunk.PromptTokens is { } prompt && chunk.CompletionTokens is { } completion && NumCtx > 0)
        {
            CurrentContextTokens = (int)Math.Min(int.MaxValue, (long)prompt + completion);
            ContextUsagePercent = (int)Math.Min(int.MaxValue, (long)CurrentContextTokens * 100 / NumCtx);
            if (ContextUsagePercent >= ContextWarningThreshold && OnContextHighUsage is { } handlers)
                foreach (var handler in handlers.GetInvocationList().Cast<Action<int>>())
                    try { handler(ContextUsagePercent); }
                    catch (Exception ex) { Logger.WriteWarning("System", $"Context warning callback failed: {ex.Message}"); }
        }
        return reply;
    }

    public void Dispose()
    {
        using var operation = _operation.EnterOrThrow("Cannot dispose a provider during a conversation operation.");
        if (_disposed) return;
        _disposed = true;
        if (_ownsHttp) Http.Dispose();
        GC.SuppressFinalize(this);
    }

}